detached fe2336c

fix: improve config migration rename detection and fix mode toaster colors

commit
fe2336cf1e0edfbbc98e35578da75fcfda52d6bd
author
brice · · 2025-12-28 06:45 UTC
parent
4e82c5d
signature
unsigned
15 files · +1134 -88
M internal/application/port/config_migrator.go +44 -1
@@ -18,14 +18,57 @@ type KeyInfo struct {
1818 DefaultValue string
1919 }
2020
21+// KeyChangeType indicates the type of config key change.
22+type KeyChangeType int
23+
24+const (
25+ // KeyChangeAdded indicates a new key was added to defaults.
26+ KeyChangeAdded KeyChangeType = iota
27+ // KeyChangeRemoved indicates a key in user config no longer exists in defaults.
28+ KeyChangeRemoved
29+ // KeyChangeRenamed indicates a key was renamed (detected via similarity).
30+ KeyChangeRenamed
31+ // KeyChangeConsolidated indicates multiple keys were merged into one.
32+ KeyChangeConsolidated
33+)
34+
35+// String returns a display symbol for the change type.
36+func (t KeyChangeType) String() string {
37+ switch t {
38+ case KeyChangeAdded:
39+ return "+"
40+ case KeyChangeRemoved:
41+ return "-"
42+ case KeyChangeRenamed:
43+ return "~"
44+ case KeyChangeConsolidated:
45+ return ">"
46+ default:
47+ return "?"
48+ }
49+}
50+
51+// KeyChange represents a detected change between user config and defaults.
52+type KeyChange struct {
53+ Type KeyChangeType // Type of change
54+ OldKey string // Old key name (for removed/renamed)
55+ NewKey string // New key name (for added/renamed)
56+ OldValue string // Old value (for renamed/consolidated)
57+ NewValue string // New/default value
58+}
59+
2160 // ConfigMigrator checks for and applies config migrations.
2261 type ConfigMigrator interface {
2362 // CheckMigration checks if user config is missing any default keys.
2463 // Returns nil if no migration is needed (config file doesn't exist or is complete).
2564 CheckMigration() (*MigrationResult, error)
2665
66+ // DetectChanges analyzes user config and returns all detected changes.
67+ // This provides a detailed diff-like view of what migration would do.
68+ DetectChanges() ([]KeyChange, error)
69+
2770 // Migrate adds missing default keys to the user's config file.
28- // Returns the list of keys that were added.
71+ // Returns the list of keys that were added/renamed.
2972 Migrate() ([]string, error)
3073
3174 // GetKeyInfo returns detailed information about a config key.
@@ -18,14 +18,57 @@ type KeyInfo struct {
18 DefaultValue string18 DefaultValue string
19 }19 }
20 20
21+// KeyChangeType indicates the type of config key change.
22+type KeyChangeType int
23+
24+const (
25+ // KeyChangeAdded indicates a new key was added to defaults.
26+ KeyChangeAdded KeyChangeType = iota
27+ // KeyChangeRemoved indicates a key in user config no longer exists in defaults.
28+ KeyChangeRemoved
29+ // KeyChangeRenamed indicates a key was renamed (detected via similarity).
30+ KeyChangeRenamed
31+ // KeyChangeConsolidated indicates multiple keys were merged into one.
32+ KeyChangeConsolidated
33+)
34+
35+// String returns a display symbol for the change type.
36+func (t KeyChangeType) String() string {
37+ switch t {
38+ case KeyChangeAdded:
39+ return "+"
40+ case KeyChangeRemoved:
41+ return "-"
42+ case KeyChangeRenamed:
43+ return "~"
44+ case KeyChangeConsolidated:
45+ return ">"
46+ default:
47+ return "?"
48+ }
49+}
50+
51+// KeyChange represents a detected change between user config and defaults.
52+type KeyChange struct {
53+ Type KeyChangeType // Type of change
54+ OldKey string // Old key name (for removed/renamed)
55+ NewKey string // New key name (for added/renamed)
56+ OldValue string // Old value (for renamed/consolidated)
57+ NewValue string // New/default value
58+}
59+
21 // ConfigMigrator checks for and applies config migrations.60 // ConfigMigrator checks for and applies config migrations.
22 type ConfigMigrator interface {61 type ConfigMigrator interface {
23 // CheckMigration checks if user config is missing any default keys.62 // CheckMigration checks if user config is missing any default keys.
24 // Returns nil if no migration is needed (config file doesn't exist or is complete).63 // Returns nil if no migration is needed (config file doesn't exist or is complete).
25 CheckMigration() (*MigrationResult, error)64 CheckMigration() (*MigrationResult, error)
26 65
66+ // DetectChanges analyzes user config and returns all detected changes.
67+ // This provides a detailed diff-like view of what migration would do.
68+ DetectChanges() ([]KeyChange, error)
69+
27 // Migrate adds missing default keys to the user's config file.70 // Migrate adds missing default keys to the user's config file.
28- // Returns the list of keys that were added.71+ // Returns the list of keys that were added/renamed.
29 Migrate() ([]string, error)72 Migrate() ([]string, error)
30 73
31 // GetKeyInfo returns detailed information about a config key.74 // GetKeyInfo returns detailed information about a config key.
M internal/application/port/mocks/mocks.go +55 -0
@@ -96,6 +96,61 @@ func (_c *MockConfigMigrator_CheckMigration_Call) RunAndReturn(run func() (*port
9696 return _c
9797 }
9898
99+// DetectChanges provides a mock function for the type MockConfigMigrator
100+func (_mock *MockConfigMigrator) DetectChanges() ([]port.KeyChange, error) {
101+ ret := _mock.Called()
102+
103+ if len(ret) == 0 {
104+ panic("no return value specified for DetectChanges")
105+ }
106+
107+ var r0 []port.KeyChange
108+ var r1 error
109+ if returnFunc, ok := ret.Get(0).(func() ([]port.KeyChange, error)); ok {
110+ return returnFunc()
111+ }
112+ if returnFunc, ok := ret.Get(0).(func() []port.KeyChange); ok {
113+ r0 = returnFunc()
114+ } else {
115+ if ret.Get(0) != nil {
116+ r0 = ret.Get(0).([]port.KeyChange)
117+ }
118+ }
119+ if returnFunc, ok := ret.Get(1).(func() error); ok {
120+ r1 = returnFunc()
121+ } else {
122+ r1 = ret.Error(1)
123+ }
124+ return r0, r1
125+}
126+
127+// MockConfigMigrator_DetectChanges_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DetectChanges'
128+type MockConfigMigrator_DetectChanges_Call struct {
129+ *mock.Call
130+}
131+
132+// DetectChanges is a helper method to define mock.On call
133+func (_e *MockConfigMigrator_Expecter) DetectChanges() *MockConfigMigrator_DetectChanges_Call {
134+ return &MockConfigMigrator_DetectChanges_Call{Call: _e.mock.On("DetectChanges")}
135+}
136+
137+func (_c *MockConfigMigrator_DetectChanges_Call) Run(run func()) *MockConfigMigrator_DetectChanges_Call {
138+ _c.Call.Run(func(args mock.Arguments) {
139+ run()
140+ })
141+ return _c
142+}
143+
144+func (_c *MockConfigMigrator_DetectChanges_Call) Return(keyChanges []port.KeyChange, err error) *MockConfigMigrator_DetectChanges_Call {
145+ _c.Call.Return(keyChanges, err)
146+ return _c
147+}
148+
149+func (_c *MockConfigMigrator_DetectChanges_Call) RunAndReturn(run func() ([]port.KeyChange, error)) *MockConfigMigrator_DetectChanges_Call {
150+ _c.Call.Return(run)
151+ return _c
152+}
153+
99154 // GetKeyInfo provides a mock function for the type MockConfigMigrator
100155 func (_mock *MockConfigMigrator) GetKeyInfo(key string) port.KeyInfo {
101156 ret := _mock.Called(key)
@@ -96,6 +96,61 @@ func (_c *MockConfigMigrator_CheckMigration_Call) RunAndReturn(run func() (*port
96 return _c96 return _c
97 }97 }
98 98
99+// DetectChanges provides a mock function for the type MockConfigMigrator
100+func (_mock *MockConfigMigrator) DetectChanges() ([]port.KeyChange, error) {
101+ ret := _mock.Called()
102+
103+ if len(ret) == 0 {
104+ panic("no return value specified for DetectChanges")
105+ }
106+
107+ var r0 []port.KeyChange
108+ var r1 error
109+ if returnFunc, ok := ret.Get(0).(func() ([]port.KeyChange, error)); ok {
110+ return returnFunc()
111+ }
112+ if returnFunc, ok := ret.Get(0).(func() []port.KeyChange); ok {
113+ r0 = returnFunc()
114+ } else {
115+ if ret.Get(0) != nil {
116+ r0 = ret.Get(0).([]port.KeyChange)
117+ }
118+ }
119+ if returnFunc, ok := ret.Get(1).(func() error); ok {
120+ r1 = returnFunc()
121+ } else {
122+ r1 = ret.Error(1)
123+ }
124+ return r0, r1
125+}
126+
127+// MockConfigMigrator_DetectChanges_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DetectChanges'
128+type MockConfigMigrator_DetectChanges_Call struct {
129+ *mock.Call
130+}
131+
132+// DetectChanges is a helper method to define mock.On call
133+func (_e *MockConfigMigrator_Expecter) DetectChanges() *MockConfigMigrator_DetectChanges_Call {
134+ return &MockConfigMigrator_DetectChanges_Call{Call: _e.mock.On("DetectChanges")}
135+}
136+
137+func (_c *MockConfigMigrator_DetectChanges_Call) Run(run func()) *MockConfigMigrator_DetectChanges_Call {
138+ _c.Call.Run(func(args mock.Arguments) {
139+ run()
140+ })
141+ return _c
142+}
143+
144+func (_c *MockConfigMigrator_DetectChanges_Call) Return(keyChanges []port.KeyChange, err error) *MockConfigMigrator_DetectChanges_Call {
145+ _c.Call.Return(keyChanges, err)
146+ return _c
147+}
148+
149+func (_c *MockConfigMigrator_DetectChanges_Call) RunAndReturn(run func() ([]port.KeyChange, error)) *MockConfigMigrator_DetectChanges_Call {
150+ _c.Call.Return(run)
151+ return _c
152+}
153+
99 // GetKeyInfo provides a mock function for the type MockConfigMigrator154 // GetKeyInfo provides a mock function for the type MockConfigMigrator
100 func (_mock *MockConfigMigrator) GetKeyInfo(key string) port.KeyInfo {155 func (_mock *MockConfigMigrator) GetKeyInfo(key string) port.KeyInfo {
101 ret := _mock.Called(key)156 ret := _mock.Called(key)
M internal/application/usecase/migrate_config.go +71 -0
@@ -2,6 +2,8 @@ package usecase
22
33 import (
44 "context"
5+ "fmt"
6+ "strings"
57
68 "github.com/bnema/dumber/internal/application/port"
79 "github.com/bnema/dumber/internal/logging"
@@ -20,6 +22,19 @@ type CheckConfigMigrationOutput struct {
2022 ConfigFile string
2123 }
2224
25+// DetectChangesInput holds the input for detecting config changes.
26+type DetectChangesInput struct{}
27+
28+// DetectChangesOutput holds the result of change detection.
29+type DetectChangesOutput struct {
30+ // HasChanges is true if any changes were detected.
31+ HasChanges bool
32+ // Changes contains all detected changes.
33+ Changes []port.KeyChange
34+ // DiffText is a formatted diff-like string representation.
35+ DiffText string
36+}
37+
2338 // MigrateConfigInput holds the input for migrating config.
2439 type MigrateConfigInput struct{}
2540
@@ -81,6 +96,36 @@ func (uc *MigrateConfigUseCase) Check(ctx context.Context, _ CheckConfigMigratio
8196 }, nil
8297 }
8398
99+// DetectChanges detects all config changes and returns a diff-like output.
100+func (uc *MigrateConfigUseCase) DetectChanges(ctx context.Context, _ DetectChangesInput) (*DetectChangesOutput, error) {
101+ log := logging.FromContext(ctx)
102+
103+ changes, err := uc.migrator.DetectChanges()
104+ if err != nil {
105+ log.Warn().Err(err).Msg("config change detection failed")
106+ return nil, err
107+ }
108+
109+ if len(changes) == 0 {
110+ log.Debug().Msg("no config changes detected")
111+ return &DetectChangesOutput{
112+ HasChanges: false,
113+ Changes: nil,
114+ DiffText: "No changes detected.",
115+ }, nil
116+ }
117+
118+ log.Debug().
119+ Int("changes", len(changes)).
120+ Msg("config changes detected")
121+
122+ return &DetectChangesOutput{
123+ HasChanges: true,
124+ Changes: changes,
125+ DiffText: formatChangesAsDiff(changes),
126+ }, nil
127+}
128+
84129 // Execute adds missing default keys to the user's config file.
85130 func (uc *MigrateConfigUseCase) Execute(ctx context.Context, _ MigrateConfigInput) (*MigrateConfigOutput, error) {
86131 log := logging.FromContext(ctx)
@@ -116,3 +161,29 @@ func (uc *MigrateConfigUseCase) Execute(ctx context.Context, _ MigrateConfigInpu
116161 ConfigFile: checkResult.ConfigFile,
117162 }, nil
118163 }
164+
165+// formatChangesAsDiff returns changes formatted as a diff for display.
166+func formatChangesAsDiff(changes []port.KeyChange) string {
167+ if len(changes) == 0 {
168+ return "No changes detected."
169+ }
170+
171+ var sb strings.Builder
172+ sb.WriteString("Config migration changes:\n\n")
173+
174+ for _, change := range changes {
175+ switch change.Type {
176+ case port.KeyChangeAdded:
177+ sb.WriteString(fmt.Sprintf(" + %s = %s\n", change.NewKey, change.NewValue))
178+ case port.KeyChangeRemoved:
179+ sb.WriteString(fmt.Sprintf(" - %s = %s (deprecated)\n", change.OldKey, change.OldValue))
180+ case port.KeyChangeRenamed:
181+ sb.WriteString(fmt.Sprintf(" ~ %s -> %s\n", change.OldKey, change.NewKey))
182+ sb.WriteString(fmt.Sprintf(" (value: %s)\n", change.OldValue))
183+ case port.KeyChangeConsolidated:
184+ sb.WriteString(fmt.Sprintf(" > %s -> %s\n", change.OldKey, change.NewKey))
185+ }
186+ }
187+
188+ return sb.String()
189+}
@@ -2,6 +2,8 @@ package usecase
2 2
3 import (3 import (
4 "context"4 "context"
5+ "fmt"
6+ "strings"
5 7
6 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
7 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
@@ -20,6 +22,19 @@ type CheckConfigMigrationOutput struct {
20 ConfigFile string22 ConfigFile string
21 }23 }
22 24
25+// DetectChangesInput holds the input for detecting config changes.
26+type DetectChangesInput struct{}
27+
28+// DetectChangesOutput holds the result of change detection.
29+type DetectChangesOutput struct {
30+ // HasChanges is true if any changes were detected.
31+ HasChanges bool
32+ // Changes contains all detected changes.
33+ Changes []port.KeyChange
34+ // DiffText is a formatted diff-like string representation.
35+ DiffText string
36+}
37+
23 // MigrateConfigInput holds the input for migrating config.38 // MigrateConfigInput holds the input for migrating config.
24 type MigrateConfigInput struct{}39 type MigrateConfigInput struct{}
25 40
@@ -81,6 +96,36 @@ func (uc *MigrateConfigUseCase) Check(ctx context.Context, _ CheckConfigMigratio
81 }, nil96 }, nil
82 }97 }
83 98
99+// DetectChanges detects all config changes and returns a diff-like output.
100+func (uc *MigrateConfigUseCase) DetectChanges(ctx context.Context, _ DetectChangesInput) (*DetectChangesOutput, error) {
101+ log := logging.FromContext(ctx)
102+
103+ changes, err := uc.migrator.DetectChanges()
104+ if err != nil {
105+ log.Warn().Err(err).Msg("config change detection failed")
106+ return nil, err
107+ }
108+
109+ if len(changes) == 0 {
110+ log.Debug().Msg("no config changes detected")
111+ return &DetectChangesOutput{
112+ HasChanges: false,
113+ Changes: nil,
114+ DiffText: "No changes detected.",
115+ }, nil
116+ }
117+
118+ log.Debug().
119+ Int("changes", len(changes)).
120+ Msg("config changes detected")
121+
122+ return &DetectChangesOutput{
123+ HasChanges: true,
124+ Changes: changes,
125+ DiffText: formatChangesAsDiff(changes),
126+ }, nil
127+}
128+
84 // Execute adds missing default keys to the user's config file.129 // Execute adds missing default keys to the user's config file.
85 func (uc *MigrateConfigUseCase) Execute(ctx context.Context, _ MigrateConfigInput) (*MigrateConfigOutput, error) {130 func (uc *MigrateConfigUseCase) Execute(ctx context.Context, _ MigrateConfigInput) (*MigrateConfigOutput, error) {
86 log := logging.FromContext(ctx)131 log := logging.FromContext(ctx)
@@ -116,3 +161,29 @@ func (uc *MigrateConfigUseCase) Execute(ctx context.Context, _ MigrateConfigInpu
116 ConfigFile: checkResult.ConfigFile,161 ConfigFile: checkResult.ConfigFile,
117 }, nil162 }, nil
118 }163 }
164+
165+// formatChangesAsDiff returns changes formatted as a diff for display.
166+func formatChangesAsDiff(changes []port.KeyChange) string {
167+ if len(changes) == 0 {
168+ return "No changes detected."
169+ }
170+
171+ var sb strings.Builder
172+ sb.WriteString("Config migration changes:\n\n")
173+
174+ for _, change := range changes {
175+ switch change.Type {
176+ case port.KeyChangeAdded:
177+ sb.WriteString(fmt.Sprintf(" + %s = %s\n", change.NewKey, change.NewValue))
178+ case port.KeyChangeRemoved:
179+ sb.WriteString(fmt.Sprintf(" - %s = %s (deprecated)\n", change.OldKey, change.OldValue))
180+ case port.KeyChangeRenamed:
181+ sb.WriteString(fmt.Sprintf(" ~ %s -> %s\n", change.OldKey, change.NewKey))
182+ sb.WriteString(fmt.Sprintf(" (value: %s)\n", change.OldValue))
183+ case port.KeyChangeConsolidated:
184+ sb.WriteString(fmt.Sprintf(" > %s -> %s\n", change.OldKey, change.NewKey))
185+ }
186+ }
187+
188+ return sb.String()
189+}
M internal/application/usecase/migrate_config_test.go +182 -0
@@ -204,3 +204,185 @@ func TestMigrateConfigUseCase_MockExpectations(t *testing.T) {
204204 assert.True(t, result.NeedsMigration)
205205 mockMigrator.AssertExpectations(t)
206206 }
207+
208+func TestMigrateConfigUseCase_DetectChanges_NoChanges(t *testing.T) {
209+ mockMigrator := mocks.NewMockConfigMigrator(t)
210+ mockMigrator.EXPECT().DetectChanges().Return(nil, nil)
211+
212+ uc := NewMigrateConfigUseCase(mockMigrator)
213+ ctx := context.Background()
214+
215+ result, err := uc.DetectChanges(ctx, DetectChangesInput{})
216+
217+ require.NoError(t, err)
218+ assert.False(t, result.HasChanges)
219+ assert.Empty(t, result.Changes)
220+ assert.Equal(t, "No changes detected.", result.DiffText)
221+}
222+
223+func TestMigrateConfigUseCase_DetectChanges_WithAddedKey(t *testing.T) {
224+ mockMigrator := mocks.NewMockConfigMigrator(t)
225+
226+ changes := []port.KeyChange{
227+ {
228+ Type: port.KeyChangeAdded,
229+ NewKey: "workspace.styling.mode_indicator_toaster_enabled",
230+ NewValue: "true",
231+ },
232+ }
233+ mockMigrator.EXPECT().DetectChanges().Return(changes, nil)
234+
235+ uc := NewMigrateConfigUseCase(mockMigrator)
236+ ctx := context.Background()
237+
238+ result, err := uc.DetectChanges(ctx, DetectChangesInput{})
239+
240+ require.NoError(t, err)
241+ assert.True(t, result.HasChanges)
242+ assert.Len(t, result.Changes, 1)
243+ assert.Contains(t, result.DiffText, "+ workspace.styling.mode_indicator_toaster_enabled")
244+}
245+
246+func TestMigrateConfigUseCase_DetectChanges_WithRenamedKey(t *testing.T) {
247+ mockMigrator := mocks.NewMockConfigMigrator(t)
248+
249+ changes := []port.KeyChange{
250+ {
251+ Type: port.KeyChangeRenamed,
252+ OldKey: "workspace.styling.pane_mode_border_color",
253+ NewKey: "workspace.styling.pane_mode_color",
254+ OldValue: `"#4A90E2"`,
255+ },
256+ }
257+ mockMigrator.EXPECT().DetectChanges().Return(changes, nil)
258+
259+ uc := NewMigrateConfigUseCase(mockMigrator)
260+ ctx := context.Background()
261+
262+ result, err := uc.DetectChanges(ctx, DetectChangesInput{})
263+
264+ require.NoError(t, err)
265+ assert.True(t, result.HasChanges)
266+ assert.Len(t, result.Changes, 1)
267+ assert.Contains(t, result.DiffText, "~ workspace.styling.pane_mode_border_color -> workspace.styling.pane_mode_color")
268+}
269+
270+func TestMigrateConfigUseCase_DetectChanges_WithRemovedKey(t *testing.T) {
271+ mockMigrator := mocks.NewMockConfigMigrator(t)
272+
273+ changes := []port.KeyChange{
274+ {
275+ Type: port.KeyChangeRemoved,
276+ OldKey: "workspace.styling.pane_mode_border_width",
277+ OldValue: "4",
278+ },
279+ }
280+ mockMigrator.EXPECT().DetectChanges().Return(changes, nil)
281+
282+ uc := NewMigrateConfigUseCase(mockMigrator)
283+ ctx := context.Background()
284+
285+ result, err := uc.DetectChanges(ctx, DetectChangesInput{})
286+
287+ require.NoError(t, err)
288+ assert.True(t, result.HasChanges)
289+ assert.Len(t, result.Changes, 1)
290+ assert.Contains(t, result.DiffText, "- workspace.styling.pane_mode_border_width")
291+ assert.Contains(t, result.DiffText, "(deprecated)")
292+}
293+
294+func TestMigrateConfigUseCase_DetectChanges_Error(t *testing.T) {
295+ mockMigrator := mocks.NewMockConfigMigrator(t)
296+ expectedErr := errors.New("detect changes failed")
297+ mockMigrator.EXPECT().DetectChanges().Return(nil, expectedErr)
298+
299+ uc := NewMigrateConfigUseCase(mockMigrator)
300+ ctx := context.Background()
301+
302+ result, err := uc.DetectChanges(ctx, DetectChangesInput{})
303+
304+ require.Error(t, err)
305+ assert.Nil(t, result)
306+ assert.Equal(t, expectedErr, err)
307+}
308+
309+func TestMigrateConfigUseCase_DetectChanges_MultipleChanges(t *testing.T) {
310+ mockMigrator := mocks.NewMockConfigMigrator(t)
311+
312+ changes := []port.KeyChange{
313+ {
314+ Type: port.KeyChangeAdded,
315+ NewKey: "workspace.styling.mode_border_width",
316+ NewValue: "4",
317+ },
318+ {
319+ Type: port.KeyChangeRenamed,
320+ OldKey: "workspace.styling.pane_mode_border_color",
321+ NewKey: "workspace.styling.pane_mode_color",
322+ OldValue: `"#4A90E2"`,
323+ },
324+ {
325+ Type: port.KeyChangeRemoved,
326+ OldKey: "workspace.styling.pane_mode_border_width",
327+ OldValue: "4",
328+ },
329+ }
330+ mockMigrator.EXPECT().DetectChanges().Return(changes, nil)
331+
332+ uc := NewMigrateConfigUseCase(mockMigrator)
333+ ctx := context.Background()
334+
335+ result, err := uc.DetectChanges(ctx, DetectChangesInput{})
336+
337+ require.NoError(t, err)
338+ assert.True(t, result.HasChanges)
339+ assert.Len(t, result.Changes, 3)
340+
341+ // Verify diff contains all change types
342+ assert.Contains(t, result.DiffText, "+ workspace.styling.mode_border_width")
343+ assert.Contains(t, result.DiffText, "~ workspace.styling.pane_mode_border_color")
344+ assert.Contains(t, result.DiffText, "- workspace.styling.pane_mode_border_width")
345+}
346+
347+func TestFormatChangesAsDiff_EmptyChanges(t *testing.T) {
348+ result := formatChangesAsDiff(nil)
349+ assert.Equal(t, "No changes detected.", result)
350+
351+ result = formatChangesAsDiff([]port.KeyChange{})
352+ assert.Equal(t, "No changes detected.", result)
353+}
354+
355+func TestFormatChangesAsDiff_AllChangeTypes(t *testing.T) {
356+ changes := []port.KeyChange{
357+ {
358+ Type: port.KeyChangeAdded,
359+ NewKey: "new.key",
360+ NewValue: `"value"`,
361+ },
362+ {
363+ Type: port.KeyChangeRemoved,
364+ OldKey: "old.key",
365+ OldValue: `"old_value"`,
366+ },
367+ {
368+ Type: port.KeyChangeRenamed,
369+ OldKey: "renamed.old",
370+ NewKey: "renamed.new",
371+ OldValue: `"renamed_value"`,
372+ },
373+ {
374+ Type: port.KeyChangeConsolidated,
375+ OldKey: "consolidated.old",
376+ NewKey: "consolidated.new",
377+ },
378+ }
379+
380+ result := formatChangesAsDiff(changes)
381+
382+ assert.Contains(t, result, "Config migration changes:")
383+ assert.Contains(t, result, `+ new.key = "value"`)
384+ assert.Contains(t, result, `- old.key = "old_value" (deprecated)`)
385+ assert.Contains(t, result, "~ renamed.old -> renamed.new")
386+ assert.Contains(t, result, `(value: "renamed_value")`)
387+ assert.Contains(t, result, "> consolidated.old -> consolidated.new")
388+}
@@ -204,3 +204,185 @@ func TestMigrateConfigUseCase_MockExpectations(t *testing.T) {
204 assert.True(t, result.NeedsMigration)204 assert.True(t, result.NeedsMigration)
205 mockMigrator.AssertExpectations(t)205 mockMigrator.AssertExpectations(t)
206 }206 }
207+
208+func TestMigrateConfigUseCase_DetectChanges_NoChanges(t *testing.T) {
209+ mockMigrator := mocks.NewMockConfigMigrator(t)
210+ mockMigrator.EXPECT().DetectChanges().Return(nil, nil)
211+
212+ uc := NewMigrateConfigUseCase(mockMigrator)
213+ ctx := context.Background()
214+
215+ result, err := uc.DetectChanges(ctx, DetectChangesInput{})
216+
217+ require.NoError(t, err)
218+ assert.False(t, result.HasChanges)
219+ assert.Empty(t, result.Changes)
220+ assert.Equal(t, "No changes detected.", result.DiffText)
221+}
222+
223+func TestMigrateConfigUseCase_DetectChanges_WithAddedKey(t *testing.T) {
224+ mockMigrator := mocks.NewMockConfigMigrator(t)
225+
226+ changes := []port.KeyChange{
227+ {
228+ Type: port.KeyChangeAdded,
229+ NewKey: "workspace.styling.mode_indicator_toaster_enabled",
230+ NewValue: "true",
231+ },
232+ }
233+ mockMigrator.EXPECT().DetectChanges().Return(changes, nil)
234+
235+ uc := NewMigrateConfigUseCase(mockMigrator)
236+ ctx := context.Background()
237+
238+ result, err := uc.DetectChanges(ctx, DetectChangesInput{})
239+
240+ require.NoError(t, err)
241+ assert.True(t, result.HasChanges)
242+ assert.Len(t, result.Changes, 1)
243+ assert.Contains(t, result.DiffText, "+ workspace.styling.mode_indicator_toaster_enabled")
244+}
245+
246+func TestMigrateConfigUseCase_DetectChanges_WithRenamedKey(t *testing.T) {
247+ mockMigrator := mocks.NewMockConfigMigrator(t)
248+
249+ changes := []port.KeyChange{
250+ {
251+ Type: port.KeyChangeRenamed,
252+ OldKey: "workspace.styling.pane_mode_border_color",
253+ NewKey: "workspace.styling.pane_mode_color",
254+ OldValue: `"#4A90E2"`,
255+ },
256+ }
257+ mockMigrator.EXPECT().DetectChanges().Return(changes, nil)
258+
259+ uc := NewMigrateConfigUseCase(mockMigrator)
260+ ctx := context.Background()
261+
262+ result, err := uc.DetectChanges(ctx, DetectChangesInput{})
263+
264+ require.NoError(t, err)
265+ assert.True(t, result.HasChanges)
266+ assert.Len(t, result.Changes, 1)
267+ assert.Contains(t, result.DiffText, "~ workspace.styling.pane_mode_border_color -> workspace.styling.pane_mode_color")
268+}
269+
270+func TestMigrateConfigUseCase_DetectChanges_WithRemovedKey(t *testing.T) {
271+ mockMigrator := mocks.NewMockConfigMigrator(t)
272+
273+ changes := []port.KeyChange{
274+ {
275+ Type: port.KeyChangeRemoved,
276+ OldKey: "workspace.styling.pane_mode_border_width",
277+ OldValue: "4",
278+ },
279+ }
280+ mockMigrator.EXPECT().DetectChanges().Return(changes, nil)
281+
282+ uc := NewMigrateConfigUseCase(mockMigrator)
283+ ctx := context.Background()
284+
285+ result, err := uc.DetectChanges(ctx, DetectChangesInput{})
286+
287+ require.NoError(t, err)
288+ assert.True(t, result.HasChanges)
289+ assert.Len(t, result.Changes, 1)
290+ assert.Contains(t, result.DiffText, "- workspace.styling.pane_mode_border_width")
291+ assert.Contains(t, result.DiffText, "(deprecated)")
292+}
293+
294+func TestMigrateConfigUseCase_DetectChanges_Error(t *testing.T) {
295+ mockMigrator := mocks.NewMockConfigMigrator(t)
296+ expectedErr := errors.New("detect changes failed")
297+ mockMigrator.EXPECT().DetectChanges().Return(nil, expectedErr)
298+
299+ uc := NewMigrateConfigUseCase(mockMigrator)
300+ ctx := context.Background()
301+
302+ result, err := uc.DetectChanges(ctx, DetectChangesInput{})
303+
304+ require.Error(t, err)
305+ assert.Nil(t, result)
306+ assert.Equal(t, expectedErr, err)
307+}
308+
309+func TestMigrateConfigUseCase_DetectChanges_MultipleChanges(t *testing.T) {
310+ mockMigrator := mocks.NewMockConfigMigrator(t)
311+
312+ changes := []port.KeyChange{
313+ {
314+ Type: port.KeyChangeAdded,
315+ NewKey: "workspace.styling.mode_border_width",
316+ NewValue: "4",
317+ },
318+ {
319+ Type: port.KeyChangeRenamed,
320+ OldKey: "workspace.styling.pane_mode_border_color",
321+ NewKey: "workspace.styling.pane_mode_color",
322+ OldValue: `"#4A90E2"`,
323+ },
324+ {
325+ Type: port.KeyChangeRemoved,
326+ OldKey: "workspace.styling.pane_mode_border_width",
327+ OldValue: "4",
328+ },
329+ }
330+ mockMigrator.EXPECT().DetectChanges().Return(changes, nil)
331+
332+ uc := NewMigrateConfigUseCase(mockMigrator)
333+ ctx := context.Background()
334+
335+ result, err := uc.DetectChanges(ctx, DetectChangesInput{})
336+
337+ require.NoError(t, err)
338+ assert.True(t, result.HasChanges)
339+ assert.Len(t, result.Changes, 3)
340+
341+ // Verify diff contains all change types
342+ assert.Contains(t, result.DiffText, "+ workspace.styling.mode_border_width")
343+ assert.Contains(t, result.DiffText, "~ workspace.styling.pane_mode_border_color")
344+ assert.Contains(t, result.DiffText, "- workspace.styling.pane_mode_border_width")
345+}
346+
347+func TestFormatChangesAsDiff_EmptyChanges(t *testing.T) {
348+ result := formatChangesAsDiff(nil)
349+ assert.Equal(t, "No changes detected.", result)
350+
351+ result = formatChangesAsDiff([]port.KeyChange{})
352+ assert.Equal(t, "No changes detected.", result)
353+}
354+
355+func TestFormatChangesAsDiff_AllChangeTypes(t *testing.T) {
356+ changes := []port.KeyChange{
357+ {
358+ Type: port.KeyChangeAdded,
359+ NewKey: "new.key",
360+ NewValue: `"value"`,
361+ },
362+ {
363+ Type: port.KeyChangeRemoved,
364+ OldKey: "old.key",
365+ OldValue: `"old_value"`,
366+ },
367+ {
368+ Type: port.KeyChangeRenamed,
369+ OldKey: "renamed.old",
370+ NewKey: "renamed.new",
371+ OldValue: `"renamed_value"`,
372+ },
373+ {
374+ Type: port.KeyChangeConsolidated,
375+ OldKey: "consolidated.old",
376+ NewKey: "consolidated.new",
377+ },
378+ }
379+
380+ result := formatChangesAsDiff(changes)
381+
382+ assert.Contains(t, result, "Config migration changes:")
383+ assert.Contains(t, result, `+ new.key = "value"`)
384+ assert.Contains(t, result, `- old.key = "old_value" (deprecated)`)
385+ assert.Contains(t, result, "~ renamed.old -> renamed.new")
386+ assert.Contains(t, result, `(value: "renamed_value")`)
387+ assert.Contains(t, result, "> consolidated.old -> consolidated.new")
388+}
M internal/infrastructure/config/defaults.go +18 -24
@@ -46,21 +46,17 @@ const (
4646 defaultBorderWidth = 1
4747 defaultBorderColor = "@theme_selected_bg_color"
4848
49- // Pane mode border (Ctrl+P N - overlay)
50- defaultPaneModeBorderWidth = 4
51- defaultPaneModeBorderColor = "#4A90E2" // Blue for pane mode indicator
49+ // Mode border width (consolidated for all modes)
50+ defaultModeBorderWidth = 4
5251
53- // Tab mode border (Ctrl+P T - overlay)
54- defaultTabModeBorderWidth = 4
55- defaultTabModeBorderColor = "#FFA500" // Orange for tab mode indicator
52+ // Mode colors (used for both borders and toaster)
53+ defaultPaneModeColor = "#4A90E2" // Blue for pane mode
54+ defaultTabModeColor = "#FFA500" // Orange for tab mode
55+ defaultSessionModeColor = "#9B59B6" // Purple for session mode
56+ defaultResizeModeColor = "#00D4AA" // Cyan/teal for resize mode
5657
57- // Session mode border (Ctrl+O - overlay)
58- defaultSessionModeBorderWidth = 4
59- defaultSessionModeBorderColor = "#9B59B6" // Purple for session mode indicator
60-
61- // Resize mode border (Ctrl+N - overlay)
62- defaultResizeModeBorderWidth = 4
63- defaultResizeModeBorderColor = "#00D4AA" // Cyan/teal for resize mode indicator
58+ // Mode indicator toaster
59+ defaultModeIndicatorToasterEnabled = true
6460
6561 // Other styling
6662 defaultTransitionDuration = 120
@@ -238,17 +234,15 @@ func DefaultConfig() *Config {
238234 OAuthAutoClose: true, // Auto-close OAuth popups on success
239235 },
240236 Styling: WorkspaceStylingConfig{
241- BorderWidth: defaultBorderWidth,
242- BorderColor: defaultBorderColor,
243- PaneModeBorderWidth: defaultPaneModeBorderWidth,
244- PaneModeBorderColor: defaultPaneModeBorderColor,
245- TabModeBorderWidth: defaultTabModeBorderWidth,
246- TabModeBorderColor: defaultTabModeBorderColor,
247- SessionModeBorderWidth: defaultSessionModeBorderWidth,
248- SessionModeBorderColor: defaultSessionModeBorderColor,
249- ResizeModeBorderWidth: defaultResizeModeBorderWidth,
250- ResizeModeBorderColor: defaultResizeModeBorderColor,
251- TransitionDuration: defaultTransitionDuration,
237+ BorderWidth: defaultBorderWidth,
238+ BorderColor: defaultBorderColor,
239+ ModeBorderWidth: defaultModeBorderWidth,
240+ PaneModeColor: defaultPaneModeColor,
241+ TabModeColor: defaultTabModeColor,
242+ SessionModeColor: defaultSessionModeColor,
243+ ResizeModeColor: defaultResizeModeColor,
244+ ModeIndicatorToasterEnabled: defaultModeIndicatorToasterEnabled,
245+ TransitionDuration: defaultTransitionDuration,
252246 },
253247 },
254248 ContentFiltering: ContentFilteringConfig{
@@ -46,21 +46,17 @@ const (
46 defaultBorderWidth = 146 defaultBorderWidth = 1
47 defaultBorderColor = "@theme_selected_bg_color"47 defaultBorderColor = "@theme_selected_bg_color"
48 48
49- // Pane mode border (Ctrl+P N - overlay)49+ // Mode border width (consolidated for all modes)
50- defaultPaneModeBorderWidth = 450+ defaultModeBorderWidth = 4
51- defaultPaneModeBorderColor = "#4A90E2" // Blue for pane mode indicator
52 51
53- // Tab mode border (Ctrl+P T - overlay)52+ // Mode colors (used for both borders and toaster)
54- defaultTabModeBorderWidth = 453+ defaultPaneModeColor = "#4A90E2" // Blue for pane mode
55- defaultTabModeBorderColor = "#FFA500" // Orange for tab mode indicator54+ defaultTabModeColor = "#FFA500" // Orange for tab mode
55+ defaultSessionModeColor = "#9B59B6" // Purple for session mode
56+ defaultResizeModeColor = "#00D4AA" // Cyan/teal for resize mode
56 57
57- // Session mode border (Ctrl+O - overlay)58+ // Mode indicator toaster
58- defaultSessionModeBorderWidth = 459+ defaultModeIndicatorToasterEnabled = true
59- defaultSessionModeBorderColor = "#9B59B6" // Purple for session mode indicator
60-
61- // Resize mode border (Ctrl+N - overlay)
62- defaultResizeModeBorderWidth = 4
63- defaultResizeModeBorderColor = "#00D4AA" // Cyan/teal for resize mode indicator
64 60
65 // Other styling61 // Other styling
66 defaultTransitionDuration = 12062 defaultTransitionDuration = 120
@@ -238,17 +234,15 @@ func DefaultConfig() *Config {
238 OAuthAutoClose: true, // Auto-close OAuth popups on success234 OAuthAutoClose: true, // Auto-close OAuth popups on success
239 },235 },
240 Styling: WorkspaceStylingConfig{236 Styling: WorkspaceStylingConfig{
241- BorderWidth: defaultBorderWidth,237+ BorderWidth: defaultBorderWidth,
242- BorderColor: defaultBorderColor,238+ BorderColor: defaultBorderColor,
243- PaneModeBorderWidth: defaultPaneModeBorderWidth,239+ ModeBorderWidth: defaultModeBorderWidth,
244- PaneModeBorderColor: defaultPaneModeBorderColor,240+ PaneModeColor: defaultPaneModeColor,
245- TabModeBorderWidth: defaultTabModeBorderWidth,241+ TabModeColor: defaultTabModeColor,
246- TabModeBorderColor: defaultTabModeBorderColor,242+ SessionModeColor: defaultSessionModeColor,
247- SessionModeBorderWidth: defaultSessionModeBorderWidth,243+ ResizeModeColor: defaultResizeModeColor,
248- SessionModeBorderColor: defaultSessionModeBorderColor,244+ ModeIndicatorToasterEnabled: defaultModeIndicatorToasterEnabled,
249- ResizeModeBorderWidth: defaultResizeModeBorderWidth,245+ TransitionDuration: defaultTransitionDuration,
250- ResizeModeBorderColor: defaultResizeModeBorderColor,
251- TransitionDuration: defaultTransitionDuration,
252 },246 },
253 },247 },
254 ContentFiltering: ContentFilteringConfig{248 ContentFiltering: ContentFilteringConfig{
M internal/infrastructure/config/loader.go +6 -8
@@ -411,14 +411,12 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
411411 m.viper.SetDefault("workspace.popups.oauth_auto_close", defaults.Workspace.Popups.OAuthAutoClose)
412412 m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth)
413413 m.viper.SetDefault("workspace.styling.border_color", defaults.Workspace.Styling.BorderColor)
414- m.viper.SetDefault("workspace.styling.pane_mode_border_width", defaults.Workspace.Styling.PaneModeBorderWidth)
415- m.viper.SetDefault("workspace.styling.pane_mode_border_color", defaults.Workspace.Styling.PaneModeBorderColor)
416- m.viper.SetDefault("workspace.styling.tab_mode_border_width", defaults.Workspace.Styling.TabModeBorderWidth)
417- m.viper.SetDefault("workspace.styling.tab_mode_border_color", defaults.Workspace.Styling.TabModeBorderColor)
418- m.viper.SetDefault("workspace.styling.session_mode_border_width", defaults.Workspace.Styling.SessionModeBorderWidth)
419- m.viper.SetDefault("workspace.styling.session_mode_border_color", defaults.Workspace.Styling.SessionModeBorderColor)
420- m.viper.SetDefault("workspace.styling.resize_mode_border_width", defaults.Workspace.Styling.ResizeModeBorderWidth)
421- m.viper.SetDefault("workspace.styling.resize_mode_border_color", defaults.Workspace.Styling.ResizeModeBorderColor)
414+ m.viper.SetDefault("workspace.styling.mode_border_width", defaults.Workspace.Styling.ModeBorderWidth)
415+ m.viper.SetDefault("workspace.styling.pane_mode_color", defaults.Workspace.Styling.PaneModeColor)
416+ m.viper.SetDefault("workspace.styling.tab_mode_color", defaults.Workspace.Styling.TabModeColor)
417+ m.viper.SetDefault("workspace.styling.session_mode_color", defaults.Workspace.Styling.SessionModeColor)
418+ m.viper.SetDefault("workspace.styling.resize_mode_color", defaults.Workspace.Styling.ResizeModeColor)
419+ m.viper.SetDefault("workspace.styling.mode_indicator_toaster_enabled", defaults.Workspace.Styling.ModeIndicatorToasterEnabled)
422420 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)
423421 }
424422
@@ -411,14 +411,12 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
411 m.viper.SetDefault("workspace.popups.oauth_auto_close", defaults.Workspace.Popups.OAuthAutoClose)411 m.viper.SetDefault("workspace.popups.oauth_auto_close", defaults.Workspace.Popups.OAuthAutoClose)
412 m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth)412 m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth)
413 m.viper.SetDefault("workspace.styling.border_color", defaults.Workspace.Styling.BorderColor)413 m.viper.SetDefault("workspace.styling.border_color", defaults.Workspace.Styling.BorderColor)
414- m.viper.SetDefault("workspace.styling.pane_mode_border_width", defaults.Workspace.Styling.PaneModeBorderWidth)414+ m.viper.SetDefault("workspace.styling.mode_border_width", defaults.Workspace.Styling.ModeBorderWidth)
415- m.viper.SetDefault("workspace.styling.pane_mode_border_color", defaults.Workspace.Styling.PaneModeBorderColor)415+ m.viper.SetDefault("workspace.styling.pane_mode_color", defaults.Workspace.Styling.PaneModeColor)
416- m.viper.SetDefault("workspace.styling.tab_mode_border_width", defaults.Workspace.Styling.TabModeBorderWidth)416+ m.viper.SetDefault("workspace.styling.tab_mode_color", defaults.Workspace.Styling.TabModeColor)
417- m.viper.SetDefault("workspace.styling.tab_mode_border_color", defaults.Workspace.Styling.TabModeBorderColor)417+ m.viper.SetDefault("workspace.styling.session_mode_color", defaults.Workspace.Styling.SessionModeColor)
418- m.viper.SetDefault("workspace.styling.session_mode_border_width", defaults.Workspace.Styling.SessionModeBorderWidth)418+ m.viper.SetDefault("workspace.styling.resize_mode_color", defaults.Workspace.Styling.ResizeModeColor)
419- m.viper.SetDefault("workspace.styling.session_mode_border_color", defaults.Workspace.Styling.SessionModeBorderColor)419+ m.viper.SetDefault("workspace.styling.mode_indicator_toaster_enabled", defaults.Workspace.Styling.ModeIndicatorToasterEnabled)
420- m.viper.SetDefault("workspace.styling.resize_mode_border_width", defaults.Workspace.Styling.ResizeModeBorderWidth)
421- m.viper.SetDefault("workspace.styling.resize_mode_border_color", defaults.Workspace.Styling.ResizeModeBorderColor)
422 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)420 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)
423 }421 }
424 422
M internal/infrastructure/config/migrate.go +362 -8
@@ -71,20 +71,291 @@ func (m *Migrator) CheckMigration() (*port.MigrationResult, error) {
7171 }, nil
7272 }
7373
74+// DetectChanges analyzes user config and returns all detected changes.
75+// This provides a detailed diff-like view of what migration would do.
76+func (m *Migrator) DetectChanges() ([]port.KeyChange, error) {
77+ configFile, err := GetConfigFile()
78+ if err != nil {
79+ return nil, fmt.Errorf("failed to get config file path: %w", err)
80+ }
81+
82+ // If config file doesn't exist, no changes to detect
83+ if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
84+ return nil, nil
85+ }
86+
87+ // Get user-defined keys with values
88+ userKeysWithValues, err := m.getUserConfigKeysWithValues(configFile)
89+ if err != nil {
90+ return nil, fmt.Errorf("failed to parse user config: %w", err)
91+ }
92+
93+ // Get all default keys
94+ defaultKeys := m.getAllDefaultKeys()
95+ defaultKeySet := make(map[string]bool)
96+ for _, k := range defaultKeys {
97+ defaultKeySet[k] = true
98+ }
99+
100+ // Build user key set for lookup
101+ userKeySet := make(map[string]bool)
102+ for k := range userKeysWithValues {
103+ userKeySet[k] = true
104+ }
105+
106+ var changes []port.KeyChange
107+
108+ // Find deprecated keys (in user config but not in defaults)
109+ deprecatedKeys := m.findDeprecatedKeys(userKeysWithValues, defaultKeySet)
110+
111+ // Find missing keys (in defaults but not in user config)
112+ missingKeys := m.findMissingKeys(defaultKeys, userKeySet)
113+
114+ // Try to match deprecated keys with missing keys (detect renames)
115+ renames, unmatchedDeprecated, unmatchedMissing := m.matchRenamedKeys(deprecatedKeys, missingKeys)
116+
117+ // Add rename changes
118+ for oldKey, newKey := range renames {
119+ oldValue := m.formatValue(userKeysWithValues[oldKey])
120+ newValue := m.formatValue(m.defaultViper.Get(newKey))
121+ changes = append(changes, port.KeyChange{
122+ Type: port.KeyChangeRenamed,
123+ OldKey: oldKey,
124+ NewKey: newKey,
125+ OldValue: oldValue,
126+ NewValue: newValue,
127+ })
128+ }
129+
130+ // Add removed changes (deprecated keys that couldn't be matched)
131+ for _, oldKey := range unmatchedDeprecated {
132+ changes = append(changes, port.KeyChange{
133+ Type: port.KeyChangeRemoved,
134+ OldKey: oldKey,
135+ OldValue: m.formatValue(userKeysWithValues[oldKey]),
136+ })
137+ }
138+
139+ // Add new key changes (missing keys that couldn't be matched)
140+ for _, newKey := range unmatchedMissing {
141+ changes = append(changes, port.KeyChange{
142+ Type: port.KeyChangeAdded,
143+ NewKey: newKey,
144+ NewValue: m.formatValue(m.defaultViper.Get(newKey)),
145+ })
146+ }
147+
148+ // Sort changes for consistent output
149+ sort.Slice(changes, func(i, j int) bool {
150+ // Sort by type first, then by key
151+ if changes[i].Type != changes[j].Type {
152+ return changes[i].Type < changes[j].Type
153+ }
154+ keyI := changes[i].NewKey
155+ if keyI == "" {
156+ keyI = changes[i].OldKey
157+ }
158+ keyJ := changes[j].NewKey
159+ if keyJ == "" {
160+ keyJ = changes[j].OldKey
161+ }
162+ return keyI < keyJ
163+ })
164+
165+ return changes, nil
166+}
167+
168+// findDeprecatedKeys returns user keys that don't exist in defaults.
169+func (m *Migrator) findDeprecatedKeys(userKeys map[string]any, defaultKeys map[string]bool) []string {
170+ var deprecated []string
171+ for userKey := range userKeys {
172+ if !m.keyOrRelatedExistsInDefaults(userKey, defaultKeys) {
173+ deprecated = append(deprecated, userKey)
174+ }
175+ }
176+ sort.Strings(deprecated)
177+ return deprecated
178+}
179+
180+// keyOrRelatedExistsInDefaults checks if a user key exists in defaults.
181+func (*Migrator) keyOrRelatedExistsInDefaults(userKey string, defaultKeys map[string]bool) bool {
182+ if defaultKeys[userKey] {
183+ return true
184+ }
185+
186+ // Check if this is a parent of any default key
187+ userKeyPrefix := userKey + "."
188+ for defaultKey := range defaultKeys {
189+ if strings.HasPrefix(defaultKey, userKeyPrefix) {
190+ return true
191+ }
192+ }
193+
194+ // Check if any parent of this user key exists
195+ parts := strings.Split(userKey, ".")
196+ for i := len(parts) - 1; i > 0; i-- {
197+ parentKey := strings.Join(parts[:i], ".")
198+ if defaultKeys[parentKey] {
199+ return true
200+ }
201+ }
202+
203+ return false
204+}
205+
206+// matchRenamedKeys attempts to match deprecated keys with missing keys.
207+// Returns: renames map, unmatched deprecated, unmatched missing.
208+func (m *Migrator) matchRenamedKeys(deprecated, missing []string) (map[string]string, []string, []string) {
209+ renames := make(map[string]string)
210+ usedDeprecated := make(map[string]bool)
211+ usedMissing := make(map[string]bool)
212+
213+ // First pass: exact suffix matching (e.g., "foo_border_color" -> "foo_color")
214+ for _, oldKey := range deprecated {
215+ for _, newKey := range missing {
216+ if usedMissing[newKey] {
217+ continue
218+ }
219+
220+ // Check if keys are similar (same parent path + related leaf names)
221+ // AND have compatible types (don't match int keys with string keys)
222+ if keysAreSimilar(oldKey, newKey) && m.typesAreCompatible(oldKey, newKey) {
223+ renames[oldKey] = newKey
224+ usedDeprecated[oldKey] = true
225+ usedMissing[newKey] = true
226+ break
227+ }
228+ }
229+ }
230+
231+ // Collect unmatched
232+ var unmatchedDeprecated []string
233+ for _, k := range deprecated {
234+ if !usedDeprecated[k] {
235+ unmatchedDeprecated = append(unmatchedDeprecated, k)
236+ }
237+ }
238+
239+ var unmatchedMissing []string
240+ for _, k := range missing {
241+ if !usedMissing[k] {
242+ unmatchedMissing = append(unmatchedMissing, k)
243+ }
244+ }
245+
246+ return renames, unmatchedDeprecated, unmatchedMissing
247+}
248+
249+// Type name constants for migration compatibility checks.
250+const (
251+ typeNameInt = "int"
252+ typeNameString = "string"
253+)
254+
255+// typesAreCompatible checks if the default types for two keys are compatible.
256+// This prevents matching int keys with string keys during rename detection.
257+func (m *Migrator) typesAreCompatible(oldKey, newKey string) bool {
258+ // Get the type of the new key from defaults
259+ newValue := m.defaultViper.Get(newKey)
260+ if newValue == nil {
261+ return false
262+ }
263+
264+ newType := m.getTypeName(newValue)
265+
266+ // Infer expected old type from key name patterns
267+ // Keys ending in "_width" are typically int
268+ // Keys ending in "_color" are typically string
269+ if strings.HasSuffix(oldKey, "_width") && newType == typeNameString {
270+ return false
271+ }
272+ if strings.HasSuffix(oldKey, "_color") && newType == typeNameInt {
273+ return false
274+ }
275+
276+ // Additional checks based on new key patterns
277+ if strings.HasSuffix(newKey, "_width") && newType != typeNameInt {
278+ return false
279+ }
280+ if strings.HasSuffix(newKey, "_color") && newType != typeNameString {
281+ return false
282+ }
283+
284+ return true
285+}
286+
287+// keysAreSimilar checks if two keys are likely renames of each other.
288+func keysAreSimilar(oldKey, newKey string) bool {
289+ oldParts := strings.Split(oldKey, ".")
290+ newParts := strings.Split(newKey, ".")
291+
292+ // Must have same parent path (all but last part)
293+ if len(oldParts) != len(newParts) {
294+ return false
295+ }
296+ if len(oldParts) < 2 {
297+ return false
298+ }
299+
300+ // Check parent path matches
301+ for i := 0; i < len(oldParts)-1; i++ {
302+ if oldParts[i] != newParts[i] {
303+ return false
304+ }
305+ }
306+
307+ // Check if leaf names are similar (one is substring of the other, or share a common base)
308+ oldLeaf := oldParts[len(oldParts)-1]
309+ newLeaf := newParts[len(newParts)-1]
310+
311+ // Direct substring check
312+ if strings.Contains(oldLeaf, newLeaf) || strings.Contains(newLeaf, oldLeaf) {
313+ return true
314+ }
315+
316+ // Common prefix/suffix matching (e.g., "pane_mode_border_color" vs "pane_mode_color")
317+ // Extract common parts by splitting on underscore
318+ oldTokens := strings.Split(oldLeaf, "_")
319+ newTokens := strings.Split(newLeaf, "_")
320+
321+ // Count matching tokens
322+ matches := 0
323+ for _, ot := range oldTokens {
324+ for _, nt := range newTokens {
325+ if ot == nt {
326+ matches++
327+ break
328+ }
329+ }
330+ }
331+
332+ // If most tokens match, consider similar
333+ minTokens := len(oldTokens)
334+ if len(newTokens) < minTokens {
335+ minTokens = len(newTokens)
336+ }
337+ return matches >= minTokens-1 && matches > 0
338+}
339+
74340 // Migrate adds missing default keys to the user's config file.
75341 func (m *Migrator) Migrate() ([]string, error) {
76- result, err := m.CheckMigration()
342+ changes, err := m.DetectChanges()
77343 if err != nil {
78344 return nil, err
79345 }
80346
81- if result == nil || len(result.MissingKeys) == 0 {
347+ if len(changes) == 0 {
82348 return nil, nil
83349 }
84350
351+ configFile, err := GetConfigFile()
352+ if err != nil {
353+ return nil, fmt.Errorf("failed to get config file path: %w", err)
354+ }
355+
85356 // Create a new Viper instance to read user config
86357 userViper := viper.New()
87- userViper.SetConfigFile(result.ConfigFile)
358+ userViper.SetConfigFile(configFile)
88359 userViper.SetConfigType("toml")
89360
90361 // Set all defaults first
@@ -96,12 +367,31 @@ func (m *Migrator) Migrate() ([]string, error) {
96367 return nil, fmt.Errorf("failed to read config file: %w", err)
97368 }
98369
370+ // Apply changes
371+ var appliedKeys []string
372+ for _, change := range changes {
373+ switch change.Type {
374+ case port.KeyChangeRenamed:
375+ // Copy value from old key to new key
376+ if userViper.IsSet(change.OldKey) {
377+ userViper.Set(change.NewKey, userViper.Get(change.OldKey))
378+ appliedKeys = append(appliedKeys, fmt.Sprintf("%s -> %s", change.OldKey, change.NewKey))
379+ }
380+ case port.KeyChangeAdded:
381+ // New key is already set via defaults, just track it
382+ appliedKeys = append(appliedKeys, change.NewKey)
383+ case port.KeyChangeRemoved:
384+ // Nothing to do - old key will be preserved but not used
385+ appliedKeys = append(appliedKeys, fmt.Sprintf("(deprecated: %s)", change.OldKey))
386+ }
387+ }
388+
99389 // Write the merged config back
100390 if err := userViper.WriteConfig(); err != nil {
101391 return nil, fmt.Errorf("failed to write config file: %w", err)
102392 }
103393
104- return result.MissingKeys, nil
394+ return appliedKeys, nil
105395 }
106396
107397 // GetKeyInfo returns detailed information about a config key.
@@ -142,6 +432,20 @@ func (m *Migrator) getAllDefaultKeys() []string {
142432
143433 // getUserConfigKeys parses the user's TOML file and returns all defined keys.
144434 func (m *Migrator) getUserConfigKeys(configFile string) (map[string]bool, error) {
435+ keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
436+ if err != nil {
437+ return nil, err
438+ }
439+
440+ keys := make(map[string]bool)
441+ for k := range keysWithValues {
442+ keys[k] = true
443+ }
444+ return keys, nil
445+}
446+
447+// getUserConfigKeysWithValues parses the user's TOML file and returns keys with their values.
448+func (m *Migrator) getUserConfigKeysWithValues(configFile string) (map[string]any, error) {
145449 data, err := os.ReadFile(configFile)
146450 if err != nil {
147451 return nil, fmt.Errorf("failed to read config file: %w", err)
@@ -153,11 +457,35 @@ func (m *Migrator) getUserConfigKeys(configFile string) (map[string]bool, error)
153457 return nil, fmt.Errorf("failed to parse TOML: %w", err)
154458 }
155459
156- // Flatten the map to dot-notation keys
157- keys := make(map[string]bool)
158- m.flattenMap(rawConfig, "", keys)
460+ // Flatten the map to dot-notation keys with values
461+ result := make(map[string]any)
462+ m.flattenMapWithValues(rawConfig, "", result)
159463
160- return keys, nil
464+ return result, nil
465+}
466+
467+// flattenMapWithValues recursively flattens a nested map to dot-notation keys with values.
468+func (m *Migrator) flattenMapWithValues(data map[string]any, prefix string, result map[string]any) {
469+ for k, v := range data {
470+ key := k
471+ if prefix != "" {
472+ key = prefix + "." + k
473+ }
474+
475+ switch val := v.(type) {
476+ case map[string]any:
477+ // Check if this key represents user data (like search_shortcuts, actions)
478+ // that should be treated as a single key rather than recursed into.
479+ if m.isUserDataSection(key) {
480+ result[key] = v
481+ } else {
482+ // Recurse into nested config sections
483+ m.flattenMapWithValues(val, key, result)
484+ }
485+ default:
486+ result[key] = v
487+ }
488+ }
161489 }
162490
163491 // flattenMap recursively flattens a nested map to dot-notation keys.
@@ -339,3 +667,29 @@ func (*Migrator) formatValue(value any) string {
339667 return fmt.Sprintf("%v", v)
340668 }
341669 }
670+
671+// FormatChangesAsDiff returns changes formatted as a diff for display.
672+func FormatChangesAsDiff(changes []port.KeyChange) string {
673+ if len(changes) == 0 {
674+ return "No changes detected."
675+ }
676+
677+ var sb strings.Builder
678+ sb.WriteString("Config migration changes:\n\n")
679+
680+ for _, change := range changes {
681+ switch change.Type {
682+ case port.KeyChangeAdded:
683+ sb.WriteString(fmt.Sprintf(" + %s = %s\n", change.NewKey, change.NewValue))
684+ case port.KeyChangeRemoved:
685+ sb.WriteString(fmt.Sprintf(" - %s = %s (deprecated)\n", change.OldKey, change.OldValue))
686+ case port.KeyChangeRenamed:
687+ sb.WriteString(fmt.Sprintf(" ~ %s -> %s\n", change.OldKey, change.NewKey))
688+ sb.WriteString(fmt.Sprintf(" (value: %s)\n", change.OldValue))
689+ case port.KeyChangeConsolidated:
690+ sb.WriteString(fmt.Sprintf(" > %s -> %s\n", change.OldKey, change.NewKey))
691+ }
692+ }
693+
694+ return sb.String()
695+}
@@ -71,20 +71,291 @@ func (m *Migrator) CheckMigration() (*port.MigrationResult, error) {
71 }, nil71 }, nil
72 }72 }
73 73
74+// DetectChanges analyzes user config and returns all detected changes.
75+// This provides a detailed diff-like view of what migration would do.
76+func (m *Migrator) DetectChanges() ([]port.KeyChange, error) {
77+ configFile, err := GetConfigFile()
78+ if err != nil {
79+ return nil, fmt.Errorf("failed to get config file path: %w", err)
80+ }
81+
82+ // If config file doesn't exist, no changes to detect
83+ if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
84+ return nil, nil
85+ }
86+
87+ // Get user-defined keys with values
88+ userKeysWithValues, err := m.getUserConfigKeysWithValues(configFile)
89+ if err != nil {
90+ return nil, fmt.Errorf("failed to parse user config: %w", err)
91+ }
92+
93+ // Get all default keys
94+ defaultKeys := m.getAllDefaultKeys()
95+ defaultKeySet := make(map[string]bool)
96+ for _, k := range defaultKeys {
97+ defaultKeySet[k] = true
98+ }
99+
100+ // Build user key set for lookup
101+ userKeySet := make(map[string]bool)
102+ for k := range userKeysWithValues {
103+ userKeySet[k] = true
104+ }
105+
106+ var changes []port.KeyChange
107+
108+ // Find deprecated keys (in user config but not in defaults)
109+ deprecatedKeys := m.findDeprecatedKeys(userKeysWithValues, defaultKeySet)
110+
111+ // Find missing keys (in defaults but not in user config)
112+ missingKeys := m.findMissingKeys(defaultKeys, userKeySet)
113+
114+ // Try to match deprecated keys with missing keys (detect renames)
115+ renames, unmatchedDeprecated, unmatchedMissing := m.matchRenamedKeys(deprecatedKeys, missingKeys)
116+
117+ // Add rename changes
118+ for oldKey, newKey := range renames {
119+ oldValue := m.formatValue(userKeysWithValues[oldKey])
120+ newValue := m.formatValue(m.defaultViper.Get(newKey))
121+ changes = append(changes, port.KeyChange{
122+ Type: port.KeyChangeRenamed,
123+ OldKey: oldKey,
124+ NewKey: newKey,
125+ OldValue: oldValue,
126+ NewValue: newValue,
127+ })
128+ }
129+
130+ // Add removed changes (deprecated keys that couldn't be matched)
131+ for _, oldKey := range unmatchedDeprecated {
132+ changes = append(changes, port.KeyChange{
133+ Type: port.KeyChangeRemoved,
134+ OldKey: oldKey,
135+ OldValue: m.formatValue(userKeysWithValues[oldKey]),
136+ })
137+ }
138+
139+ // Add new key changes (missing keys that couldn't be matched)
140+ for _, newKey := range unmatchedMissing {
141+ changes = append(changes, port.KeyChange{
142+ Type: port.KeyChangeAdded,
143+ NewKey: newKey,
144+ NewValue: m.formatValue(m.defaultViper.Get(newKey)),
145+ })
146+ }
147+
148+ // Sort changes for consistent output
149+ sort.Slice(changes, func(i, j int) bool {
150+ // Sort by type first, then by key
151+ if changes[i].Type != changes[j].Type {
152+ return changes[i].Type < changes[j].Type
153+ }
154+ keyI := changes[i].NewKey
155+ if keyI == "" {
156+ keyI = changes[i].OldKey
157+ }
158+ keyJ := changes[j].NewKey
159+ if keyJ == "" {
160+ keyJ = changes[j].OldKey
161+ }
162+ return keyI < keyJ
163+ })
164+
165+ return changes, nil
166+}
167+
168+// findDeprecatedKeys returns user keys that don't exist in defaults.
169+func (m *Migrator) findDeprecatedKeys(userKeys map[string]any, defaultKeys map[string]bool) []string {
170+ var deprecated []string
171+ for userKey := range userKeys {
172+ if !m.keyOrRelatedExistsInDefaults(userKey, defaultKeys) {
173+ deprecated = append(deprecated, userKey)
174+ }
175+ }
176+ sort.Strings(deprecated)
177+ return deprecated
178+}
179+
180+// keyOrRelatedExistsInDefaults checks if a user key exists in defaults.
181+func (*Migrator) keyOrRelatedExistsInDefaults(userKey string, defaultKeys map[string]bool) bool {
182+ if defaultKeys[userKey] {
183+ return true
184+ }
185+
186+ // Check if this is a parent of any default key
187+ userKeyPrefix := userKey + "."
188+ for defaultKey := range defaultKeys {
189+ if strings.HasPrefix(defaultKey, userKeyPrefix) {
190+ return true
191+ }
192+ }
193+
194+ // Check if any parent of this user key exists
195+ parts := strings.Split(userKey, ".")
196+ for i := len(parts) - 1; i > 0; i-- {
197+ parentKey := strings.Join(parts[:i], ".")
198+ if defaultKeys[parentKey] {
199+ return true
200+ }
201+ }
202+
203+ return false
204+}
205+
206+// matchRenamedKeys attempts to match deprecated keys with missing keys.
207+// Returns: renames map, unmatched deprecated, unmatched missing.
208+func (m *Migrator) matchRenamedKeys(deprecated, missing []string) (map[string]string, []string, []string) {
209+ renames := make(map[string]string)
210+ usedDeprecated := make(map[string]bool)
211+ usedMissing := make(map[string]bool)
212+
213+ // First pass: exact suffix matching (e.g., "foo_border_color" -> "foo_color")
214+ for _, oldKey := range deprecated {
215+ for _, newKey := range missing {
216+ if usedMissing[newKey] {
217+ continue
218+ }
219+
220+ // Check if keys are similar (same parent path + related leaf names)
221+ // AND have compatible types (don't match int keys with string keys)
222+ if keysAreSimilar(oldKey, newKey) && m.typesAreCompatible(oldKey, newKey) {
223+ renames[oldKey] = newKey
224+ usedDeprecated[oldKey] = true
225+ usedMissing[newKey] = true
226+ break
227+ }
228+ }
229+ }
230+
231+ // Collect unmatched
232+ var unmatchedDeprecated []string
233+ for _, k := range deprecated {
234+ if !usedDeprecated[k] {
235+ unmatchedDeprecated = append(unmatchedDeprecated, k)
236+ }
237+ }
238+
239+ var unmatchedMissing []string
240+ for _, k := range missing {
241+ if !usedMissing[k] {
242+ unmatchedMissing = append(unmatchedMissing, k)
243+ }
244+ }
245+
246+ return renames, unmatchedDeprecated, unmatchedMissing
247+}
248+
249+// Type name constants for migration compatibility checks.
250+const (
251+ typeNameInt = "int"
252+ typeNameString = "string"
253+)
254+
255+// typesAreCompatible checks if the default types for two keys are compatible.
256+// This prevents matching int keys with string keys during rename detection.
257+func (m *Migrator) typesAreCompatible(oldKey, newKey string) bool {
258+ // Get the type of the new key from defaults
259+ newValue := m.defaultViper.Get(newKey)
260+ if newValue == nil {
261+ return false
262+ }
263+
264+ newType := m.getTypeName(newValue)
265+
266+ // Infer expected old type from key name patterns
267+ // Keys ending in "_width" are typically int
268+ // Keys ending in "_color" are typically string
269+ if strings.HasSuffix(oldKey, "_width") && newType == typeNameString {
270+ return false
271+ }
272+ if strings.HasSuffix(oldKey, "_color") && newType == typeNameInt {
273+ return false
274+ }
275+
276+ // Additional checks based on new key patterns
277+ if strings.HasSuffix(newKey, "_width") && newType != typeNameInt {
278+ return false
279+ }
280+ if strings.HasSuffix(newKey, "_color") && newType != typeNameString {
281+ return false
282+ }
283+
284+ return true
285+}
286+
287+// keysAreSimilar checks if two keys are likely renames of each other.
288+func keysAreSimilar(oldKey, newKey string) bool {
289+ oldParts := strings.Split(oldKey, ".")
290+ newParts := strings.Split(newKey, ".")
291+
292+ // Must have same parent path (all but last part)
293+ if len(oldParts) != len(newParts) {
294+ return false
295+ }
296+ if len(oldParts) < 2 {
297+ return false
298+ }
299+
300+ // Check parent path matches
301+ for i := 0; i < len(oldParts)-1; i++ {
302+ if oldParts[i] != newParts[i] {
303+ return false
304+ }
305+ }
306+
307+ // Check if leaf names are similar (one is substring of the other, or share a common base)
308+ oldLeaf := oldParts[len(oldParts)-1]
309+ newLeaf := newParts[len(newParts)-1]
310+
311+ // Direct substring check
312+ if strings.Contains(oldLeaf, newLeaf) || strings.Contains(newLeaf, oldLeaf) {
313+ return true
314+ }
315+
316+ // Common prefix/suffix matching (e.g., "pane_mode_border_color" vs "pane_mode_color")
317+ // Extract common parts by splitting on underscore
318+ oldTokens := strings.Split(oldLeaf, "_")
319+ newTokens := strings.Split(newLeaf, "_")
320+
321+ // Count matching tokens
322+ matches := 0
323+ for _, ot := range oldTokens {
324+ for _, nt := range newTokens {
325+ if ot == nt {
326+ matches++
327+ break
328+ }
329+ }
330+ }
331+
332+ // If most tokens match, consider similar
333+ minTokens := len(oldTokens)
334+ if len(newTokens) < minTokens {
335+ minTokens = len(newTokens)
336+ }
337+ return matches >= minTokens-1 && matches > 0
338+}
339+
74 // Migrate adds missing default keys to the user's config file.340 // Migrate adds missing default keys to the user's config file.
75 func (m *Migrator) Migrate() ([]string, error) {341 func (m *Migrator) Migrate() ([]string, error) {
76- result, err := m.CheckMigration()342+ changes, err := m.DetectChanges()
77 if err != nil {343 if err != nil {
78 return nil, err344 return nil, err
79 }345 }
80 346
81- if result == nil || len(result.MissingKeys) == 0 {347+ if len(changes) == 0 {
82 return nil, nil348 return nil, nil
83 }349 }
84 350
351+ configFile, err := GetConfigFile()
352+ if err != nil {
353+ return nil, fmt.Errorf("failed to get config file path: %w", err)
354+ }
355+
85 // Create a new Viper instance to read user config356 // Create a new Viper instance to read user config
86 userViper := viper.New()357 userViper := viper.New()
87- userViper.SetConfigFile(result.ConfigFile)358+ userViper.SetConfigFile(configFile)
88 userViper.SetConfigType("toml")359 userViper.SetConfigType("toml")
89 360
90 // Set all defaults first361 // Set all defaults first
@@ -96,12 +367,31 @@ func (m *Migrator) Migrate() ([]string, error) {
96 return nil, fmt.Errorf("failed to read config file: %w", err)367 return nil, fmt.Errorf("failed to read config file: %w", err)
97 }368 }
98 369
370+ // Apply changes
371+ var appliedKeys []string
372+ for _, change := range changes {
373+ switch change.Type {
374+ case port.KeyChangeRenamed:
375+ // Copy value from old key to new key
376+ if userViper.IsSet(change.OldKey) {
377+ userViper.Set(change.NewKey, userViper.Get(change.OldKey))
378+ appliedKeys = append(appliedKeys, fmt.Sprintf("%s -> %s", change.OldKey, change.NewKey))
379+ }
380+ case port.KeyChangeAdded:
381+ // New key is already set via defaults, just track it
382+ appliedKeys = append(appliedKeys, change.NewKey)
383+ case port.KeyChangeRemoved:
384+ // Nothing to do - old key will be preserved but not used
385+ appliedKeys = append(appliedKeys, fmt.Sprintf("(deprecated: %s)", change.OldKey))
386+ }
387+ }
388+
99 // Write the merged config back389 // Write the merged config back
100 if err := userViper.WriteConfig(); err != nil {390 if err := userViper.WriteConfig(); err != nil {
101 return nil, fmt.Errorf("failed to write config file: %w", err)391 return nil, fmt.Errorf("failed to write config file: %w", err)
102 }392 }
103 393
104- return result.MissingKeys, nil394+ return appliedKeys, nil
105 }395 }
106 396
107 // GetKeyInfo returns detailed information about a config key.397 // GetKeyInfo returns detailed information about a config key.
@@ -142,6 +432,20 @@ func (m *Migrator) getAllDefaultKeys() []string {
142 432
143 // getUserConfigKeys parses the user's TOML file and returns all defined keys.433 // getUserConfigKeys parses the user's TOML file and returns all defined keys.
144 func (m *Migrator) getUserConfigKeys(configFile string) (map[string]bool, error) {434 func (m *Migrator) getUserConfigKeys(configFile string) (map[string]bool, error) {
435+ keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
436+ if err != nil {
437+ return nil, err
438+ }
439+
440+ keys := make(map[string]bool)
441+ for k := range keysWithValues {
442+ keys[k] = true
443+ }
444+ return keys, nil
445+}
446+
447+// getUserConfigKeysWithValues parses the user's TOML file and returns keys with their values.
448+func (m *Migrator) getUserConfigKeysWithValues(configFile string) (map[string]any, error) {
145 data, err := os.ReadFile(configFile)449 data, err := os.ReadFile(configFile)
146 if err != nil {450 if err != nil {
147 return nil, fmt.Errorf("failed to read config file: %w", err)451 return nil, fmt.Errorf("failed to read config file: %w", err)
@@ -153,11 +457,35 @@ func (m *Migrator) getUserConfigKeys(configFile string) (map[string]bool, error)
153 return nil, fmt.Errorf("failed to parse TOML: %w", err)457 return nil, fmt.Errorf("failed to parse TOML: %w", err)
154 }458 }
155 459
156- // Flatten the map to dot-notation keys460+ // Flatten the map to dot-notation keys with values
157- keys := make(map[string]bool)461+ result := make(map[string]any)
158- m.flattenMap(rawConfig, "", keys)462+ m.flattenMapWithValues(rawConfig, "", result)
159 463
160- return keys, nil464+ return result, nil
465+}
466+
467+// flattenMapWithValues recursively flattens a nested map to dot-notation keys with values.
468+func (m *Migrator) flattenMapWithValues(data map[string]any, prefix string, result map[string]any) {
469+ for k, v := range data {
470+ key := k
471+ if prefix != "" {
472+ key = prefix + "." + k
473+ }
474+
475+ switch val := v.(type) {
476+ case map[string]any:
477+ // Check if this key represents user data (like search_shortcuts, actions)
478+ // that should be treated as a single key rather than recursed into.
479+ if m.isUserDataSection(key) {
480+ result[key] = v
481+ } else {
482+ // Recurse into nested config sections
483+ m.flattenMapWithValues(val, key, result)
484+ }
485+ default:
486+ result[key] = v
487+ }
488+ }
161 }489 }
162 490
163 // flattenMap recursively flattens a nested map to dot-notation keys.491 // flattenMap recursively flattens a nested map to dot-notation keys.
@@ -339,3 +667,29 @@ func (*Migrator) formatValue(value any) string {
339 return fmt.Sprintf("%v", v)667 return fmt.Sprintf("%v", v)
340 }668 }
341 }669 }
670+
671+// FormatChangesAsDiff returns changes formatted as a diff for display.
672+func FormatChangesAsDiff(changes []port.KeyChange) string {
673+ if len(changes) == 0 {
674+ return "No changes detected."
675+ }
676+
677+ var sb strings.Builder
678+ sb.WriteString("Config migration changes:\n\n")
679+
680+ for _, change := range changes {
681+ switch change.Type {
682+ case port.KeyChangeAdded:
683+ sb.WriteString(fmt.Sprintf(" + %s = %s\n", change.NewKey, change.NewValue))
684+ case port.KeyChangeRemoved:
685+ sb.WriteString(fmt.Sprintf(" - %s = %s (deprecated)\n", change.OldKey, change.OldValue))
686+ case port.KeyChangeRenamed:
687+ sb.WriteString(fmt.Sprintf(" ~ %s -> %s\n", change.OldKey, change.NewKey))
688+ sb.WriteString(fmt.Sprintf(" (value: %s)\n", change.OldValue))
689+ case port.KeyChangeConsolidated:
690+ sb.WriteString(fmt.Sprintf(" > %s -> %s\n", change.OldKey, change.NewKey))
691+ }
692+ }
693+
694+ return sb.String()
695+}
M internal/infrastructure/config/schema.go +15 -23
@@ -444,29 +444,21 @@ type WorkspaceStylingConfig struct {
444444 // BorderColor for focused panes (CSS color value or theme variable)
445445 BorderColor string `mapstructure:"border_color" yaml:"border_color" toml:"border_color" json:"border_color"`
446446
447- // PaneModeBorderWidth in pixels for pane mode indicator border (Ctrl+P N overlay)
448- PaneModeBorderWidth int `mapstructure:"pane_mode_border_width" yaml:"pane_mode_border_width" toml:"pane_mode_border_width" json:"pane_mode_border_width"` //nolint:lll // struct tags must stay on one line
449- // PaneModeBorderColor for the pane mode indicator border (CSS color value or theme variable)
450- // Defaults to "#4A90E2" (blue) if not set
451- PaneModeBorderColor string `mapstructure:"pane_mode_border_color" yaml:"pane_mode_border_color" toml:"pane_mode_border_color" json:"pane_mode_border_color"` //nolint:lll // struct tags must stay on one line
452-
453- // TabModeBorderWidth in pixels for tab mode indicator border (Ctrl+P T overlay)
454- TabModeBorderWidth int `mapstructure:"tab_mode_border_width" yaml:"tab_mode_border_width" toml:"tab_mode_border_width" json:"tab_mode_border_width"` //nolint:lll // struct tags must stay on one line
455- // TabModeBorderColor for the tab mode indicator border (CSS color value or theme variable)
456- // Defaults to "#FFA500" (orange) if not set - MUST be different from PaneModeBorderColor
457- TabModeBorderColor string `mapstructure:"tab_mode_border_color" yaml:"tab_mode_border_color" toml:"tab_mode_border_color" json:"tab_mode_border_color"` //nolint:lll // struct tags must stay on one line
458-
459- // SessionModeBorderWidth in pixels for session mode indicator border (Ctrl+O overlay)
460- SessionModeBorderWidth int `mapstructure:"session_mode_border_width" yaml:"session_mode_border_width" toml:"session_mode_border_width" json:"session_mode_border_width"` //nolint:lll // struct tags must stay on one line
461- // SessionModeBorderColor for the session mode indicator border (CSS color value or theme variable)
462- // Defaults to "#9B59B6" (purple) if not set - MUST be different from other mode colors
463- SessionModeBorderColor string `mapstructure:"session_mode_border_color" yaml:"session_mode_border_color" toml:"session_mode_border_color" json:"session_mode_border_color"` //nolint:lll // struct tags must stay on one line
464-
465- // ResizeModeBorderWidth in pixels for resize mode indicator border (Ctrl+N overlay)
466- ResizeModeBorderWidth int `mapstructure:"resize_mode_border_width" yaml:"resize_mode_border_width" toml:"resize_mode_border_width" json:"resize_mode_border_width"` //nolint:lll // struct tags must stay on one line
467- // ResizeModeBorderColor for the resize mode indicator border (CSS color value or theme variable)
468- // Defaults to "#00D4AA" (cyan/teal) if not set - MUST be different from other mode colors
469- ResizeModeBorderColor string `mapstructure:"resize_mode_border_color" yaml:"resize_mode_border_color" toml:"resize_mode_border_color" json:"resize_mode_border_color"` //nolint:lll // struct tags must stay on one line
447+ // ModeBorderWidth in pixels for all mode indicator borders (pane, tab, session, resize)
448+ ModeBorderWidth int `mapstructure:"mode_border_width" yaml:"mode_border_width" toml:"mode_border_width" json:"mode_border_width"` //nolint:lll // struct tags must stay on one line
449+
450+ // PaneModeColor for pane mode indicator (border and toaster). Defaults to "#4A90E2" (blue)
451+ PaneModeColor string `mapstructure:"pane_mode_color" yaml:"pane_mode_color" toml:"pane_mode_color" json:"pane_mode_color"` //nolint:lll // struct tags must stay on one line
452+ // TabModeColor for tab mode indicator (border and toaster). Defaults to "#FFA500" (orange)
453+ TabModeColor string `mapstructure:"tab_mode_color" yaml:"tab_mode_color" toml:"tab_mode_color" json:"tab_mode_color"` //nolint:lll // struct tags must stay on one line
454+ // SessionModeColor for session mode indicator (border and toaster). Defaults to "#9B59B6" (purple)
455+ SessionModeColor string `mapstructure:"session_mode_color" yaml:"session_mode_color" toml:"session_mode_color" json:"session_mode_color"` //nolint:lll // struct tags must stay on one line
456+ // ResizeModeColor for resize mode indicator (border and toaster). Defaults to "#00D4AA" (teal)
457+ ResizeModeColor string `mapstructure:"resize_mode_color" yaml:"resize_mode_color" toml:"resize_mode_color" json:"resize_mode_color"` //nolint:lll // struct tags must stay on one line
458+
459+ // ModeIndicatorToasterEnabled shows a toaster notification when modal modes are active.
460+ // Default: true
461+ ModeIndicatorToasterEnabled bool `mapstructure:"mode_indicator_toaster_enabled" yaml:"mode_indicator_toaster_enabled" toml:"mode_indicator_toaster_enabled" json:"mode_indicator_toaster_enabled"` //nolint:lll // struct tags must stay on one line
470462
471463 // TransitionDuration in milliseconds for border animations
472464 TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line
@@ -444,29 +444,21 @@ type WorkspaceStylingConfig struct {
444 // BorderColor for focused panes (CSS color value or theme variable)444 // BorderColor for focused panes (CSS color value or theme variable)
445 BorderColor string `mapstructure:"border_color" yaml:"border_color" toml:"border_color" json:"border_color"`445 BorderColor string `mapstructure:"border_color" yaml:"border_color" toml:"border_color" json:"border_color"`
446 446
447- // PaneModeBorderWidth in pixels for pane mode indicator border (Ctrl+P N overlay)447+ // ModeBorderWidth in pixels for all mode indicator borders (pane, tab, session, resize)
448- PaneModeBorderWidth int `mapstructure:"pane_mode_border_width" yaml:"pane_mode_border_width" toml:"pane_mode_border_width" json:"pane_mode_border_width"` //nolint:lll // struct tags must stay on one line448+ ModeBorderWidth int `mapstructure:"mode_border_width" yaml:"mode_border_width" toml:"mode_border_width" json:"mode_border_width"` //nolint:lll // struct tags must stay on one line
449- // PaneModeBorderColor for the pane mode indicator border (CSS color value or theme variable)449+
450- // Defaults to "#4A90E2" (blue) if not set450+ // PaneModeColor for pane mode indicator (border and toaster). Defaults to "#4A90E2" (blue)
451- PaneModeBorderColor string `mapstructure:"pane_mode_border_color" yaml:"pane_mode_border_color" toml:"pane_mode_border_color" json:"pane_mode_border_color"` //nolint:lll // struct tags must stay on one line451+ PaneModeColor string `mapstructure:"pane_mode_color" yaml:"pane_mode_color" toml:"pane_mode_color" json:"pane_mode_color"` //nolint:lll // struct tags must stay on one line
452-452+ // TabModeColor for tab mode indicator (border and toaster). Defaults to "#FFA500" (orange)
453- // TabModeBorderWidth in pixels for tab mode indicator border (Ctrl+P T overlay)453+ TabModeColor string `mapstructure:"tab_mode_color" yaml:"tab_mode_color" toml:"tab_mode_color" json:"tab_mode_color"` //nolint:lll // struct tags must stay on one line
454- TabModeBorderWidth int `mapstructure:"tab_mode_border_width" yaml:"tab_mode_border_width" toml:"tab_mode_border_width" json:"tab_mode_border_width"` //nolint:lll // struct tags must stay on one line454+ // SessionModeColor for session mode indicator (border and toaster). Defaults to "#9B59B6" (purple)
455- // TabModeBorderColor for the tab mode indicator border (CSS color value or theme variable)455+ SessionModeColor string `mapstructure:"session_mode_color" yaml:"session_mode_color" toml:"session_mode_color" json:"session_mode_color"` //nolint:lll // struct tags must stay on one line
456- // Defaults to "#FFA500" (orange) if not set - MUST be different from PaneModeBorderColor456+ // ResizeModeColor for resize mode indicator (border and toaster). Defaults to "#00D4AA" (teal)
457- TabModeBorderColor string `mapstructure:"tab_mode_border_color" yaml:"tab_mode_border_color" toml:"tab_mode_border_color" json:"tab_mode_border_color"` //nolint:lll // struct tags must stay on one line457+ ResizeModeColor string `mapstructure:"resize_mode_color" yaml:"resize_mode_color" toml:"resize_mode_color" json:"resize_mode_color"` //nolint:lll // struct tags must stay on one line
458-458+
459- // SessionModeBorderWidth in pixels for session mode indicator border (Ctrl+O overlay)459+ // ModeIndicatorToasterEnabled shows a toaster notification when modal modes are active.
460- SessionModeBorderWidth int `mapstructure:"session_mode_border_width" yaml:"session_mode_border_width" toml:"session_mode_border_width" json:"session_mode_border_width"` //nolint:lll // struct tags must stay on one line460+ // Default: true
461- // SessionModeBorderColor for the session mode indicator border (CSS color value or theme variable)461+ ModeIndicatorToasterEnabled bool `mapstructure:"mode_indicator_toaster_enabled" yaml:"mode_indicator_toaster_enabled" toml:"mode_indicator_toaster_enabled" json:"mode_indicator_toaster_enabled"` //nolint:lll // struct tags must stay on one line
462- // Defaults to "#9B59B6" (purple) if not set - MUST be different from other mode colors
463- SessionModeBorderColor string `mapstructure:"session_mode_border_color" yaml:"session_mode_border_color" toml:"session_mode_border_color" json:"session_mode_border_color"` //nolint:lll // struct tags must stay on one line
464-
465- // ResizeModeBorderWidth in pixels for resize mode indicator border (Ctrl+N overlay)
466- ResizeModeBorderWidth int `mapstructure:"resize_mode_border_width" yaml:"resize_mode_border_width" toml:"resize_mode_border_width" json:"resize_mode_border_width"` //nolint:lll // struct tags must stay on one line
467- // ResizeModeBorderColor for the resize mode indicator border (CSS color value or theme variable)
468- // Defaults to "#00D4AA" (cyan/teal) if not set - MUST be different from other mode colors
469- ResizeModeBorderColor string `mapstructure:"resize_mode_border_color" yaml:"resize_mode_border_color" toml:"resize_mode_border_color" json:"resize_mode_border_color"` //nolint:lll // struct tags must stay on one line
470 462
471 // TransitionDuration in milliseconds for border animations463 // TransitionDuration in milliseconds for border animations
472 TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line464 TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line
M internal/infrastructure/config/validation.go +2 -5
@@ -146,11 +146,8 @@ func validateWorkspaceStyling(config *Config) []string {
146146 if config.Workspace.Styling.BorderWidth < 0 {
147147 validationErrors = append(validationErrors, "workspace.styling.border_width must be non-negative")
148148 }
149- if config.Workspace.Styling.PaneModeBorderWidth < 0 {
150- validationErrors = append(validationErrors, "workspace.styling.pane_mode_border_width must be non-negative")
151- }
152- if config.Workspace.Styling.TabModeBorderWidth < 0 {
153- validationErrors = append(validationErrors, "workspace.styling.tab_mode_border_width must be non-negative")
149+ if config.Workspace.Styling.ModeBorderWidth < 0 {
150+ validationErrors = append(validationErrors, "workspace.styling.mode_border_width must be non-negative")
154151 }
155152 if config.Workspace.Styling.TransitionDuration < 0 {
156153 validationErrors = append(validationErrors, "workspace.styling.transition_duration must be non-negative")
@@ -146,11 +146,8 @@ func validateWorkspaceStyling(config *Config) []string {
146 if config.Workspace.Styling.BorderWidth < 0 {146 if config.Workspace.Styling.BorderWidth < 0 {
147 validationErrors = append(validationErrors, "workspace.styling.border_width must be non-negative")147 validationErrors = append(validationErrors, "workspace.styling.border_width must be non-negative")
148 }148 }
149- if config.Workspace.Styling.PaneModeBorderWidth < 0 {149+ if config.Workspace.Styling.ModeBorderWidth < 0 {
150- validationErrors = append(validationErrors, "workspace.styling.pane_mode_border_width must be non-negative")150+ validationErrors = append(validationErrors, "workspace.styling.mode_border_width must be non-negative")
151- }
152- if config.Workspace.Styling.TabModeBorderWidth < 0 {
153- validationErrors = append(validationErrors, "workspace.styling.tab_mode_border_width must be non-negative")
154 }151 }
155 if config.Workspace.Styling.TransitionDuration < 0 {152 if config.Workspace.Styling.TransitionDuration < 0 {
156 validationErrors = append(validationErrors, "workspace.styling.transition_duration must be non-negative")153 validationErrors = append(validationErrors, "workspace.styling.transition_duration must be non-negative")
M internal/ui/app.go +62 -0
@@ -87,6 +87,8 @@ type App struct {
8787
8888 // App-level toaster for system notifications (filter status, etc.)
8989 appToaster *component.Toaster
90+ // Mode indicator toaster for modal mode notifications (pane, tab, session, resize)
91+ modeToaster *component.Toaster
9092
9193 // Session management
9294 sessionManager *component.SessionManager
@@ -302,6 +304,18 @@ func (a *App) initAppToasterOverlay() {
302304 return
303305 }
304306 a.mainWindow.AddOverlay(gtkWidget)
307+
308+ // Create mode indicator toaster for modal mode notifications.
309+ a.modeToaster = component.NewToaster(a.widgetFactory)
310+ modeToasterWidget := a.modeToaster.Widget()
311+ if modeToasterWidget == nil {
312+ return
313+ }
314+ modeGtkWidget := modeToasterWidget.GtkWidget()
315+ if modeGtkWidget == nil {
316+ return
317+ }
318+ a.mainWindow.AddOverlay(modeGtkWidget)
305319 }
306320
307321 func (a *App) initFocusAndBorderOverlay() {
@@ -1270,6 +1284,54 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
12701284 if a.borderMgr != nil {
12711285 a.borderMgr.OnModeChange(ctx, from, to)
12721286 }
1287+
1288+ // Show/hide mode indicator toaster based on config.
1289+ a.updateModeIndicatorToaster(ctx, to)
1290+}
1291+
1292+// updateModeIndicatorToaster shows or hides the mode indicator toaster based on mode and config.
1293+func (a *App) updateModeIndicatorToaster(ctx context.Context, mode input.Mode) {
1294+ if a.modeToaster == nil {
1295+ return
1296+ }
1297+
1298+ // Check if mode indicator toaster is enabled in config.
1299+ if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.Styling.ModeIndicatorToasterEnabled {
1300+ a.modeToaster.Hide()
1301+ return
1302+ }
1303+
1304+ if mode == input.ModeNormal {
1305+ a.modeToaster.Hide()
1306+ return
1307+ }
1308+
1309+ // Show persistent toaster at bottom-left with mode display name.
1310+ // Note: Call Show() first, then ApplyModeClass() because Show() clears custom styles.
1311+ a.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo,
1312+ component.WithDuration(0), // Persistent until mode exits.
1313+ component.WithPosition(component.ToastPositionBottomLeft),
1314+ )
1315+
1316+ // Apply mode-specific CSS class after Show() to override the default level styling.
1317+ modeClass := getModeToastClass(mode)
1318+ a.modeToaster.ApplyModeClass(modeClass)
1319+}
1320+
1321+// getModeToastClass returns the CSS class for the given mode's toast styling.
1322+func getModeToastClass(mode input.Mode) string {
1323+ switch mode {
1324+ case input.ModePane:
1325+ return "toast-pane-mode"
1326+ case input.ModeTab:
1327+ return "toast-tab-mode"
1328+ case input.ModeSession:
1329+ return "toast-session-mode"
1330+ case input.ModeResize:
1331+ return "toast-resize-mode"
1332+ default:
1333+ return ""
1334+ }
12731335 }
12741336
12751337 func (a *App) clearResizeModeBorder() {
@@ -87,6 +87,8 @@ type App struct {
87 87
88 // App-level toaster for system notifications (filter status, etc.)88 // App-level toaster for system notifications (filter status, etc.)
89 appToaster *component.Toaster89 appToaster *component.Toaster
90+ // Mode indicator toaster for modal mode notifications (pane, tab, session, resize)
91+ modeToaster *component.Toaster
90 92
91 // Session management93 // Session management
92 sessionManager *component.SessionManager94 sessionManager *component.SessionManager
@@ -302,6 +304,18 @@ func (a *App) initAppToasterOverlay() {
302 return304 return
303 }305 }
304 a.mainWindow.AddOverlay(gtkWidget)306 a.mainWindow.AddOverlay(gtkWidget)
307+
308+ // Create mode indicator toaster for modal mode notifications.
309+ a.modeToaster = component.NewToaster(a.widgetFactory)
310+ modeToasterWidget := a.modeToaster.Widget()
311+ if modeToasterWidget == nil {
312+ return
313+ }
314+ modeGtkWidget := modeToasterWidget.GtkWidget()
315+ if modeGtkWidget == nil {
316+ return
317+ }
318+ a.mainWindow.AddOverlay(modeGtkWidget)
305 }319 }
306 320
307 func (a *App) initFocusAndBorderOverlay() {321 func (a *App) initFocusAndBorderOverlay() {
@@ -1270,6 +1284,54 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
1270 if a.borderMgr != nil {1284 if a.borderMgr != nil {
1271 a.borderMgr.OnModeChange(ctx, from, to)1285 a.borderMgr.OnModeChange(ctx, from, to)
1272 }1286 }
1287+
1288+ // Show/hide mode indicator toaster based on config.
1289+ a.updateModeIndicatorToaster(ctx, to)
1290+}
1291+
1292+// updateModeIndicatorToaster shows or hides the mode indicator toaster based on mode and config.
1293+func (a *App) updateModeIndicatorToaster(ctx context.Context, mode input.Mode) {
1294+ if a.modeToaster == nil {
1295+ return
1296+ }
1297+
1298+ // Check if mode indicator toaster is enabled in config.
1299+ if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.Styling.ModeIndicatorToasterEnabled {
1300+ a.modeToaster.Hide()
1301+ return
1302+ }
1303+
1304+ if mode == input.ModeNormal {
1305+ a.modeToaster.Hide()
1306+ return
1307+ }
1308+
1309+ // Show persistent toaster at bottom-left with mode display name.
1310+ // Note: Call Show() first, then ApplyModeClass() because Show() clears custom styles.
1311+ a.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo,
1312+ component.WithDuration(0), // Persistent until mode exits.
1313+ component.WithPosition(component.ToastPositionBottomLeft),
1314+ )
1315+
1316+ // Apply mode-specific CSS class after Show() to override the default level styling.
1317+ modeClass := getModeToastClass(mode)
1318+ a.modeToaster.ApplyModeClass(modeClass)
1319+}
1320+
1321+// getModeToastClass returns the CSS class for the given mode's toast styling.
1322+func getModeToastClass(mode input.Mode) string {
1323+ switch mode {
1324+ case input.ModePane:
1325+ return "toast-pane-mode"
1326+ case input.ModeTab:
1327+ return "toast-tab-mode"
1328+ case input.ModeSession:
1329+ return "toast-session-mode"
1330+ case input.ModeResize:
1331+ return "toast-resize-mode"
1332+ default:
1333+ return ""
1334+ }
1273 }1335 }
1274 1336
1275 func (a *App) clearResizeModeBorder() {1337 func (a *App) clearResizeModeBorder() {
M internal/ui/component/toaster.go +200 -8
@@ -48,6 +48,78 @@ func (l ToastLevel) cssClass() string {
4848 }
4949 }
5050
51+// ToastPosition defines where the toast appears on screen.
52+type ToastPosition int
53+
54+const (
55+ // ToastPositionTopLeft positions toast in top-left corner.
56+ ToastPositionTopLeft ToastPosition = iota
57+ // ToastPositionTopCenter positions toast at top center.
58+ ToastPositionTopCenter
59+ // ToastPositionTopRight positions toast in top-right corner.
60+ ToastPositionTopRight
61+ // ToastPositionBottomLeft positions toast in bottom-left corner.
62+ ToastPositionBottomLeft
63+ // ToastPositionBottomCenter positions toast at bottom center.
64+ ToastPositionBottomCenter
65+ // ToastPositionBottomRight positions toast in bottom-right corner.
66+ ToastPositionBottomRight
67+)
68+
69+// ToastOptions configures toast appearance and behavior.
70+type ToastOptions struct {
71+ // Duration in milliseconds. 0 = persistent (no auto-dismiss), >0 = auto-dismiss after duration.
72+ Duration int
73+ // BackgroundColor overrides the default background color (CSS color value).
74+ // Empty string uses the level's default color.
75+ BackgroundColor string
76+ // TextColor overrides the default text color (CSS color value).
77+ // Empty string uses the default (auto-contrast with background).
78+ TextColor string
79+ // Position determines where the toast appears on screen.
80+ Position ToastPosition
81+}
82+
83+// ToastOption is a functional option for configuring toast display.
84+type ToastOption func(*ToastOptions)
85+
86+// WithDuration sets the auto-dismiss duration in milliseconds.
87+// Use 0 for persistent toasts that require manual dismissal.
88+func WithDuration(ms int) ToastOption {
89+ return func(o *ToastOptions) {
90+ o.Duration = ms
91+ }
92+}
93+
94+// WithBackgroundColor sets a custom background color (CSS color value).
95+func WithBackgroundColor(color string) ToastOption {
96+ return func(o *ToastOptions) {
97+ o.BackgroundColor = color
98+ }
99+}
100+
101+// WithTextColor sets a custom text color (CSS color value).
102+func WithTextColor(color string) ToastOption {
103+ return func(o *ToastOptions) {
104+ o.TextColor = color
105+ }
106+}
107+
108+// WithPosition sets the toast position on screen.
109+func WithPosition(pos ToastPosition) ToastOption {
110+ return func(o *ToastOptions) {
111+ o.Position = pos
112+ }
113+}
114+
115+// defaultToastOptions returns the default toast options.
116+func defaultToastOptions() ToastOptions {
117+ return ToastOptions{
118+ Duration: toastDismissTimeoutMs,
119+ Position: ToastPositionTopLeft,
120+ }
121+}
122+
51123 // Toaster displays toast notifications in an overlay.
52124 // It supports different notification levels and auto-dismissal.
53125 // When a new toast is shown while one is already visible, the text
@@ -60,18 +132,24 @@ type Toaster struct {
60132 visible bool
61133 dismissTimer uint // GLib timer source ID
62134
135+ // Track current options for cleanup
136+ currentOpts ToastOptions
137+ hasCustomStyle bool
138+ customBgColor string // Current custom background color
139+ customTextColor string // Current custom text color
140+
63141 mu sync.Mutex
64142 }
65143
66144 // NewToaster creates a new toaster component for overlay display.
67-// The toaster is positioned in the top-left corner with margin.
145+// The toaster is positioned in the top-left corner with margin by default.
68146 func NewToaster(factory layout.WidgetFactory) *Toaster {
69147 // Create container box for styling and positioning
70148 container := factory.NewBox(layout.OrientationHorizontal, 0)
71149 container.AddCssClass("toast")
72150 container.AddCssClass("toast-info") // Default level
73151
74- // Position in top-left with margin
152+ // Position in top-left with margin (default)
75153 container.SetHalign(gtk.AlignStartValue)
76154 container.SetValign(gtk.AlignStartValue)
77155
@@ -98,17 +176,28 @@ func NewToaster(factory layout.WidgetFactory) *Toaster {
98176 label: label,
99177 currentLevel: ToastInfo,
100178 visible: false,
179+ currentOpts: defaultToastOptions(),
101180 }
102181 }
103182
104183 // Show displays a toast notification with the given message and level.
105184 // If a toast is already visible, updates the text and resets the dismiss timer.
106-func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel) {
185+// Optional ToastOption arguments can customize duration, colors, and position.
186+func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel, opts ...ToastOption) {
107187 log := logging.FromContext(ctx)
108188
189+ // Build options from defaults and overrides
190+ options := defaultToastOptions()
191+ for _, opt := range opts {
192+ opt(&options)
193+ }
194+
109195 t.mu.Lock()
110196 defer t.mu.Unlock()
111197
198+ // Clear previous custom styles if any
199+ t.clearCustomStyle()
200+
112201 // Update level CSS class if changed
113202 if t.currentLevel != level {
114203 t.container.RemoveCssClass(t.currentLevel.cssClass())
@@ -116,6 +205,12 @@ func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel) {
116205 t.currentLevel = level
117206 }
118207
208+ // Apply custom colors if specified
209+ t.applyCustomStyle(options)
210+
211+ // Apply position
212+ t.applyPosition(options.Position)
213+
119214 // Update message text
120215 t.label.SetText(message)
121216
@@ -131,12 +226,18 @@ func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel) {
131226 t.container.SetVisible(true)
132227 }
133228
134- // Start new dismiss timer
135- t.startDismissTimer(ctx)
229+ // Start new dismiss timer (if duration > 0)
230+ if options.Duration > 0 {
231+ t.startDismissTimerWithDuration(ctx, options.Duration)
232+ }
233+ // Duration == 0 means persistent, no timer
234+
235+ t.currentOpts = options
136236
137237 log.Debug().
138238 Str("toast_message", message).
139239 Int("toast_level", int(level)).
240+ Int("duration", options.Duration).
140241 Msg("toast shown")
141242 }
142243
@@ -167,13 +268,104 @@ func (t *Toaster) hide() {
167268 t.dismissTimer = 0
168269 }
169270
271+ // Clear custom styles
272+ t.clearCustomStyle()
273+
170274 t.visible = false
171275 t.container.SetVisible(false)
172276 }
173277
174-// startDismissTimer starts the auto-dismiss timer.
278+// applyPosition sets the widget alignment based on the position.
279+func (t *Toaster) applyPosition(pos ToastPosition) {
280+ var halign, valign gtk.Align
281+
282+ switch pos {
283+ case ToastPositionTopLeft:
284+ halign = gtk.AlignStartValue
285+ valign = gtk.AlignStartValue
286+ case ToastPositionTopCenter:
287+ halign = gtk.AlignCenterValue
288+ valign = gtk.AlignStartValue
289+ case ToastPositionTopRight:
290+ halign = gtk.AlignEndValue
291+ valign = gtk.AlignStartValue
292+ case ToastPositionBottomLeft:
293+ halign = gtk.AlignStartValue
294+ valign = gtk.AlignEndValue
295+ case ToastPositionBottomCenter:
296+ halign = gtk.AlignCenterValue
297+ valign = gtk.AlignEndValue
298+ case ToastPositionBottomRight:
299+ halign = gtk.AlignEndValue
300+ valign = gtk.AlignEndValue
301+ default:
302+ halign = gtk.AlignStartValue
303+ valign = gtk.AlignStartValue
304+ }
305+
306+ t.container.SetHalign(halign)
307+ t.container.SetValign(valign)
308+}
309+
310+// applyCustomStyle applies custom background and text colors.
311+// Uses a CSS class that is styled dynamically by the theme manager.
312+func (t *Toaster) applyCustomStyle(opts ToastOptions) {
313+ if opts.BackgroundColor == "" && opts.TextColor == "" {
314+ return
315+ }
316+
317+ // Add custom class to indicate custom styling is applied
318+ t.container.AddCssClass("toast-custom")
319+ t.hasCustomStyle = true
320+
321+ // Store the colors for access by theme/CSS generation
322+ t.customBgColor = opts.BackgroundColor
323+ t.customTextColor = opts.TextColor
324+
325+ // Remove level-based styling when using custom colors
326+ if opts.BackgroundColor != "" {
327+ t.container.RemoveCssClass(t.currentLevel.cssClass())
328+ }
329+}
330+
331+// clearCustomStyle removes any custom styling applied.
332+func (t *Toaster) clearCustomStyle() {
333+ if t.hasCustomStyle {
334+ t.container.RemoveCssClass("toast-custom")
335+ // Remove any mode-specific classes
336+ t.container.RemoveCssClass("toast-pane-mode")
337+ t.container.RemoveCssClass("toast-tab-mode")
338+ t.container.RemoveCssClass("toast-session-mode")
339+ t.container.RemoveCssClass("toast-resize-mode")
340+ // Re-add level class if it was removed
341+ if t.customBgColor != "" {
342+ t.container.AddCssClass(t.currentLevel.cssClass())
343+ }
344+ t.hasCustomStyle = false
345+ t.customBgColor = ""
346+ t.customTextColor = ""
347+ }
348+}
349+
350+// ApplyModeClass applies a mode-specific CSS class for styling.
351+// This uses the pre-defined CSS classes that reference CSS variables.
352+func (t *Toaster) ApplyModeClass(modeClass string) {
353+ t.mu.Lock()
354+ defer t.mu.Unlock()
355+
356+ // Clear any existing custom style first
357+ t.clearCustomStyle()
358+
359+ // Remove level class and add mode class
360+ t.container.RemoveCssClass(t.currentLevel.cssClass())
361+ t.container.AddCssClass(modeClass)
362+ t.hasCustomStyle = true
363+ t.customBgColor = modeClass // Store class name for cleanup tracking
364+}
365+
366+// startDismissTimerWithDuration starts the auto-dismiss timer with the given duration.
175367 // Must be called with lock held.
176-func (t *Toaster) startDismissTimer(ctx context.Context) {
368+func (t *Toaster) startDismissTimerWithDuration(ctx context.Context, durationMs int) {
177369 log := logging.FromContext(ctx)
178370
179371 cb := glib.SourceFunc(func(_ uintptr) bool {
@@ -188,7 +380,7 @@ func (t *Toaster) startDismissTimer(ctx context.Context) {
188380 return false // Don't repeat
189381 })
190382
191- t.dismissTimer = glib.TimeoutAdd(toastDismissTimeoutMs, &cb, 0)
383+ t.dismissTimer = glib.TimeoutAdd(uint(durationMs), &cb, 0)
192384 }
193385
194386 // Widget returns the underlying widget for embedding in overlays.
@@ -48,6 +48,78 @@ func (l ToastLevel) cssClass() string {
48 }48 }
49 }49 }
50 50
51+// ToastPosition defines where the toast appears on screen.
52+type ToastPosition int
53+
54+const (
55+ // ToastPositionTopLeft positions toast in top-left corner.
56+ ToastPositionTopLeft ToastPosition = iota
57+ // ToastPositionTopCenter positions toast at top center.
58+ ToastPositionTopCenter
59+ // ToastPositionTopRight positions toast in top-right corner.
60+ ToastPositionTopRight
61+ // ToastPositionBottomLeft positions toast in bottom-left corner.
62+ ToastPositionBottomLeft
63+ // ToastPositionBottomCenter positions toast at bottom center.
64+ ToastPositionBottomCenter
65+ // ToastPositionBottomRight positions toast in bottom-right corner.
66+ ToastPositionBottomRight
67+)
68+
69+// ToastOptions configures toast appearance and behavior.
70+type ToastOptions struct {
71+ // Duration in milliseconds. 0 = persistent (no auto-dismiss), >0 = auto-dismiss after duration.
72+ Duration int
73+ // BackgroundColor overrides the default background color (CSS color value).
74+ // Empty string uses the level's default color.
75+ BackgroundColor string
76+ // TextColor overrides the default text color (CSS color value).
77+ // Empty string uses the default (auto-contrast with background).
78+ TextColor string
79+ // Position determines where the toast appears on screen.
80+ Position ToastPosition
81+}
82+
83+// ToastOption is a functional option for configuring toast display.
84+type ToastOption func(*ToastOptions)
85+
86+// WithDuration sets the auto-dismiss duration in milliseconds.
87+// Use 0 for persistent toasts that require manual dismissal.
88+func WithDuration(ms int) ToastOption {
89+ return func(o *ToastOptions) {
90+ o.Duration = ms
91+ }
92+}
93+
94+// WithBackgroundColor sets a custom background color (CSS color value).
95+func WithBackgroundColor(color string) ToastOption {
96+ return func(o *ToastOptions) {
97+ o.BackgroundColor = color
98+ }
99+}
100+
101+// WithTextColor sets a custom text color (CSS color value).
102+func WithTextColor(color string) ToastOption {
103+ return func(o *ToastOptions) {
104+ o.TextColor = color
105+ }
106+}
107+
108+// WithPosition sets the toast position on screen.
109+func WithPosition(pos ToastPosition) ToastOption {
110+ return func(o *ToastOptions) {
111+ o.Position = pos
112+ }
113+}
114+
115+// defaultToastOptions returns the default toast options.
116+func defaultToastOptions() ToastOptions {
117+ return ToastOptions{
118+ Duration: toastDismissTimeoutMs,
119+ Position: ToastPositionTopLeft,
120+ }
121+}
122+
51 // Toaster displays toast notifications in an overlay.123 // Toaster displays toast notifications in an overlay.
52 // It supports different notification levels and auto-dismissal.124 // It supports different notification levels and auto-dismissal.
53 // When a new toast is shown while one is already visible, the text125 // When a new toast is shown while one is already visible, the text
@@ -60,18 +132,24 @@ type Toaster struct {
60 visible bool132 visible bool
61 dismissTimer uint // GLib timer source ID133 dismissTimer uint // GLib timer source ID
62 134
135+ // Track current options for cleanup
136+ currentOpts ToastOptions
137+ hasCustomStyle bool
138+ customBgColor string // Current custom background color
139+ customTextColor string // Current custom text color
140+
63 mu sync.Mutex141 mu sync.Mutex
64 }142 }
65 143
66 // NewToaster creates a new toaster component for overlay display.144 // NewToaster creates a new toaster component for overlay display.
67-// The toaster is positioned in the top-left corner with margin.145+// The toaster is positioned in the top-left corner with margin by default.
68 func NewToaster(factory layout.WidgetFactory) *Toaster {146 func NewToaster(factory layout.WidgetFactory) *Toaster {
69 // Create container box for styling and positioning147 // Create container box for styling and positioning
70 container := factory.NewBox(layout.OrientationHorizontal, 0)148 container := factory.NewBox(layout.OrientationHorizontal, 0)
71 container.AddCssClass("toast")149 container.AddCssClass("toast")
72 container.AddCssClass("toast-info") // Default level150 container.AddCssClass("toast-info") // Default level
73 151
74- // Position in top-left with margin152+ // Position in top-left with margin (default)
75 container.SetHalign(gtk.AlignStartValue)153 container.SetHalign(gtk.AlignStartValue)
76 container.SetValign(gtk.AlignStartValue)154 container.SetValign(gtk.AlignStartValue)
77 155
@@ -98,17 +176,28 @@ func NewToaster(factory layout.WidgetFactory) *Toaster {
98 label: label,176 label: label,
99 currentLevel: ToastInfo,177 currentLevel: ToastInfo,
100 visible: false,178 visible: false,
179+ currentOpts: defaultToastOptions(),
101 }180 }
102 }181 }
103 182
104 // Show displays a toast notification with the given message and level.183 // Show displays a toast notification with the given message and level.
105 // If a toast is already visible, updates the text and resets the dismiss timer.184 // If a toast is already visible, updates the text and resets the dismiss timer.
106-func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel) {185+// Optional ToastOption arguments can customize duration, colors, and position.
186+func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel, opts ...ToastOption) {
107 log := logging.FromContext(ctx)187 log := logging.FromContext(ctx)
108 188
189+ // Build options from defaults and overrides
190+ options := defaultToastOptions()
191+ for _, opt := range opts {
192+ opt(&options)
193+ }
194+
109 t.mu.Lock()195 t.mu.Lock()
110 defer t.mu.Unlock()196 defer t.mu.Unlock()
111 197
198+ // Clear previous custom styles if any
199+ t.clearCustomStyle()
200+
112 // Update level CSS class if changed201 // Update level CSS class if changed
113 if t.currentLevel != level {202 if t.currentLevel != level {
114 t.container.RemoveCssClass(t.currentLevel.cssClass())203 t.container.RemoveCssClass(t.currentLevel.cssClass())
@@ -116,6 +205,12 @@ func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel) {
116 t.currentLevel = level205 t.currentLevel = level
117 }206 }
118 207
208+ // Apply custom colors if specified
209+ t.applyCustomStyle(options)
210+
211+ // Apply position
212+ t.applyPosition(options.Position)
213+
119 // Update message text214 // Update message text
120 t.label.SetText(message)215 t.label.SetText(message)
121 216
@@ -131,12 +226,18 @@ func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel) {
131 t.container.SetVisible(true)226 t.container.SetVisible(true)
132 }227 }
133 228
134- // Start new dismiss timer229+ // Start new dismiss timer (if duration > 0)
135- t.startDismissTimer(ctx)230+ if options.Duration > 0 {
231+ t.startDismissTimerWithDuration(ctx, options.Duration)
232+ }
233+ // Duration == 0 means persistent, no timer
234+
235+ t.currentOpts = options
136 236
137 log.Debug().237 log.Debug().
138 Str("toast_message", message).238 Str("toast_message", message).
139 Int("toast_level", int(level)).239 Int("toast_level", int(level)).
240+ Int("duration", options.Duration).
140 Msg("toast shown")241 Msg("toast shown")
141 }242 }
142 243
@@ -167,13 +268,104 @@ func (t *Toaster) hide() {
167 t.dismissTimer = 0268 t.dismissTimer = 0
168 }269 }
169 270
271+ // Clear custom styles
272+ t.clearCustomStyle()
273+
170 t.visible = false274 t.visible = false
171 t.container.SetVisible(false)275 t.container.SetVisible(false)
172 }276 }
173 277
174-// startDismissTimer starts the auto-dismiss timer.278+// applyPosition sets the widget alignment based on the position.
279+func (t *Toaster) applyPosition(pos ToastPosition) {
280+ var halign, valign gtk.Align
281+
282+ switch pos {
283+ case ToastPositionTopLeft:
284+ halign = gtk.AlignStartValue
285+ valign = gtk.AlignStartValue
286+ case ToastPositionTopCenter:
287+ halign = gtk.AlignCenterValue
288+ valign = gtk.AlignStartValue
289+ case ToastPositionTopRight:
290+ halign = gtk.AlignEndValue
291+ valign = gtk.AlignStartValue
292+ case ToastPositionBottomLeft:
293+ halign = gtk.AlignStartValue
294+ valign = gtk.AlignEndValue
295+ case ToastPositionBottomCenter:
296+ halign = gtk.AlignCenterValue
297+ valign = gtk.AlignEndValue
298+ case ToastPositionBottomRight:
299+ halign = gtk.AlignEndValue
300+ valign = gtk.AlignEndValue
301+ default:
302+ halign = gtk.AlignStartValue
303+ valign = gtk.AlignStartValue
304+ }
305+
306+ t.container.SetHalign(halign)
307+ t.container.SetValign(valign)
308+}
309+
310+// applyCustomStyle applies custom background and text colors.
311+// Uses a CSS class that is styled dynamically by the theme manager.
312+func (t *Toaster) applyCustomStyle(opts ToastOptions) {
313+ if opts.BackgroundColor == "" && opts.TextColor == "" {
314+ return
315+ }
316+
317+ // Add custom class to indicate custom styling is applied
318+ t.container.AddCssClass("toast-custom")
319+ t.hasCustomStyle = true
320+
321+ // Store the colors for access by theme/CSS generation
322+ t.customBgColor = opts.BackgroundColor
323+ t.customTextColor = opts.TextColor
324+
325+ // Remove level-based styling when using custom colors
326+ if opts.BackgroundColor != "" {
327+ t.container.RemoveCssClass(t.currentLevel.cssClass())
328+ }
329+}
330+
331+// clearCustomStyle removes any custom styling applied.
332+func (t *Toaster) clearCustomStyle() {
333+ if t.hasCustomStyle {
334+ t.container.RemoveCssClass("toast-custom")
335+ // Remove any mode-specific classes
336+ t.container.RemoveCssClass("toast-pane-mode")
337+ t.container.RemoveCssClass("toast-tab-mode")
338+ t.container.RemoveCssClass("toast-session-mode")
339+ t.container.RemoveCssClass("toast-resize-mode")
340+ // Re-add level class if it was removed
341+ if t.customBgColor != "" {
342+ t.container.AddCssClass(t.currentLevel.cssClass())
343+ }
344+ t.hasCustomStyle = false
345+ t.customBgColor = ""
346+ t.customTextColor = ""
347+ }
348+}
349+
350+// ApplyModeClass applies a mode-specific CSS class for styling.
351+// This uses the pre-defined CSS classes that reference CSS variables.
352+func (t *Toaster) ApplyModeClass(modeClass string) {
353+ t.mu.Lock()
354+ defer t.mu.Unlock()
355+
356+ // Clear any existing custom style first
357+ t.clearCustomStyle()
358+
359+ // Remove level class and add mode class
360+ t.container.RemoveCssClass(t.currentLevel.cssClass())
361+ t.container.AddCssClass(modeClass)
362+ t.hasCustomStyle = true
363+ t.customBgColor = modeClass // Store class name for cleanup tracking
364+}
365+
366+// startDismissTimerWithDuration starts the auto-dismiss timer with the given duration.
175 // Must be called with lock held.367 // Must be called with lock held.
176-func (t *Toaster) startDismissTimer(ctx context.Context) {368+func (t *Toaster) startDismissTimerWithDuration(ctx context.Context, durationMs int) {
177 log := logging.FromContext(ctx)369 log := logging.FromContext(ctx)
178 370
179 cb := glib.SourceFunc(func(_ uintptr) bool {371 cb := glib.SourceFunc(func(_ uintptr) bool {
@@ -188,7 +380,7 @@ func (t *Toaster) startDismissTimer(ctx context.Context) {
188 return false // Don't repeat380 return false // Don't repeat
189 })381 })
190 382
191- t.dismissTimer = glib.TimeoutAdd(toastDismissTimeoutMs, &cb, 0)383+ t.dismissTimer = glib.TimeoutAdd(uint(durationMs), &cb, 0)
192 }384 }
193 385
194 // Widget returns the underlying widget for embedding in overlays.386 // Widget returns the underlying widget for embedding in overlays.
M internal/ui/input/modal.go +16 -0
@@ -42,6 +42,22 @@ func (m Mode) String() string {
4242 }
4343 }
4444
45+// DisplayName returns the mode name for display in UI (e.g., toaster).
46+func (m Mode) DisplayName() string {
47+ switch m {
48+ case ModeTab:
49+ return "TAB MODE"
50+ case ModePane:
51+ return "PANE MODE"
52+ case ModeSession:
53+ return "SESSION MODE"
54+ case ModeResize:
55+ return "RESIZE MODE"
56+ default:
57+ return ""
58+ }
59+}
60+
4561 // ModalState manages the current input mode with optional timeout.
4662 type ModalState struct {
4763 mode Mode
@@ -42,6 +42,22 @@ func (m Mode) String() string {
42 }42 }
43 }43 }
44 44
45+// DisplayName returns the mode name for display in UI (e.g., toaster).
46+func (m Mode) DisplayName() string {
47+ switch m {
48+ case ModeTab:
49+ return "TAB MODE"
50+ case ModePane:
51+ return "PANE MODE"
52+ case ModeSession:
53+ return "SESSION MODE"
54+ case ModeResize:
55+ return "RESIZE MODE"
56+ default:
57+ return ""
58+ }
59+}
60+
45 // ModalState manages the current input mode with optional timeout.61 // ModalState manages the current input mode with optional timeout.
46 type ModalState struct {62 type ModalState struct {
47 mode Mode63 mode Mode
M internal/ui/theme/css.go +43 -9
@@ -50,7 +50,14 @@ window, tooltip, popover {
5050
5151 // GenerateCSSWithScaleAndFonts creates GTK4 CSS using the provided palette, UI scale factor and fonts.
5252 // Scale affects font sizes and widget padding/margins proportionally.
53+// Uses default mode colors.
5354 func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) string {
55+ return GenerateCSSFull(p, scale, fonts, DefaultModeColors())
56+}
57+
58+// GenerateCSSFull creates GTK4 CSS using all provided configuration.
59+// Scale affects font sizes and widget padding/margins proportionally.
60+func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors ModeColors) string {
5461 if scale <= 0 {
5562 scale = 1.0
5663 }
@@ -68,6 +75,7 @@ func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) st
6875 sb.WriteString(":root {\n")
6976 sb.WriteString(p.ToCSSVars())
7077 sb.WriteString(FontCSSVars(fonts))
78+ sb.WriteString(modeColors.ToCSSVars())
7179 sb.WriteString("}\n\n")
7280
7381 // Global font styling
@@ -484,26 +492,26 @@ func generatePaneCSS(p Palette) string {
484492 border-color: transparent;
485493 }
486494
487-/* Pane mode active - thick blue inset border (for overlay) */
495+/* Pane mode active - thick inset border (for overlay) */
488496 .pane-mode-active {
489497 background-color: transparent;
490- box-shadow: inset 0 0 0 0.25em #4A90E2;
498+ box-shadow: inset 0 0 0 0.25em var(--pane-mode-color);
491499 border-radius: 0;
492500 }
493501
494-/* Tab mode active - thick orange inset border (for overlay) */
502+/* Tab mode active - thick inset border (for overlay) */
495503 .tab-mode-active {
496504 background-color: transparent;
497- box-shadow: inset 0 0 0 0.25em #FFA500;
505+ box-shadow: inset 0 0 0 0.25em var(--tab-mode-color);
498506 border-radius: 0;
499507 }
500508
501-/* Resize mode active - thick teal inset border */
509+/* Resize mode active - thick inset border */
502510 .resize-mode-active {
503511 background-color: transparent;
504512 /* Prefer inset shadow, but also set outline for widgets that don't paint shadows */
505- box-shadow: inset 0 0 0 0.25em #00D4AA;
506- outline: 0.25em solid #00D4AA;
513+ box-shadow: inset 0 0 0 0.25em var(--resize-mode-color);
514+ outline: 0.25em solid var(--resize-mode-color);
507515 outline-offset: -0.25em;
508516 border-radius: 0;
509517 }
@@ -751,10 +759,10 @@ row:selected .session-manager-row {
751759 font-family: var(--font-mono);
752760 }
753761
754-/* Session mode border (purple) */
762+/* Session mode border */
755763 .session-mode-active {
756764 background-color: transparent;
757- box-shadow: inset 0 0 0 0.25em #9B59B6;
765+ box-shadow: inset 0 0 0 0.25em var(--session-mode-color);
758766 border-radius: 0;
759767 }
760768 `
@@ -800,6 +808,32 @@ func generateToasterCSS(p Palette) string {
800808 background-color: alpha(var(--destructive), 0.9);
801809 color: var(--bg);
802810 }
811+
812+/* Toast with custom styling (mode indicator toasters) */
813+.toast-custom {
814+ color: #ffffff;
815+}
816+
817+/* Mode-specific toast colors */
818+.toast-pane-mode {
819+ background-color: var(--pane-mode-color);
820+ color: #ffffff;
821+}
822+
823+.toast-tab-mode {
824+ background-color: var(--tab-mode-color);
825+ color: #ffffff;
826+}
827+
828+.toast-session-mode {
829+ background-color: var(--session-mode-color);
830+ color: #ffffff;
831+}
832+
833+.toast-resize-mode {
834+ background-color: var(--resize-mode-color);
835+ color: #ffffff;
836+}
803837 `
804838 }
805839
@@ -50,7 +50,14 @@ window, tooltip, popover {
50 50
51 // GenerateCSSWithScaleAndFonts creates GTK4 CSS using the provided palette, UI scale factor and fonts.51 // GenerateCSSWithScaleAndFonts creates GTK4 CSS using the provided palette, UI scale factor and fonts.
52 // Scale affects font sizes and widget padding/margins proportionally.52 // Scale affects font sizes and widget padding/margins proportionally.
53+// Uses default mode colors.
53 func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) string {54 func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) string {
55+ return GenerateCSSFull(p, scale, fonts, DefaultModeColors())
56+}
57+
58+// GenerateCSSFull creates GTK4 CSS using all provided configuration.
59+// Scale affects font sizes and widget padding/margins proportionally.
60+func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors ModeColors) string {
54 if scale <= 0 {61 if scale <= 0 {
55 scale = 1.062 scale = 1.0
56 }63 }
@@ -68,6 +75,7 @@ func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) st
68 sb.WriteString(":root {\n")75 sb.WriteString(":root {\n")
69 sb.WriteString(p.ToCSSVars())76 sb.WriteString(p.ToCSSVars())
70 sb.WriteString(FontCSSVars(fonts))77 sb.WriteString(FontCSSVars(fonts))
78+ sb.WriteString(modeColors.ToCSSVars())
71 sb.WriteString("}\n\n")79 sb.WriteString("}\n\n")
72 80
73 // Global font styling81 // Global font styling
@@ -484,26 +492,26 @@ func generatePaneCSS(p Palette) string {
484 border-color: transparent;492 border-color: transparent;
485 }493 }
486 494
487-/* Pane mode active - thick blue inset border (for overlay) */495+/* Pane mode active - thick inset border (for overlay) */
488 .pane-mode-active {496 .pane-mode-active {
489 background-color: transparent;497 background-color: transparent;
490- box-shadow: inset 0 0 0 0.25em #4A90E2;498+ box-shadow: inset 0 0 0 0.25em var(--pane-mode-color);
491 border-radius: 0;499 border-radius: 0;
492 }500 }
493 501
494-/* Tab mode active - thick orange inset border (for overlay) */502+/* Tab mode active - thick inset border (for overlay) */
495 .tab-mode-active {503 .tab-mode-active {
496 background-color: transparent;504 background-color: transparent;
497- box-shadow: inset 0 0 0 0.25em #FFA500;505+ box-shadow: inset 0 0 0 0.25em var(--tab-mode-color);
498 border-radius: 0;506 border-radius: 0;
499 }507 }
500 508
501-/* Resize mode active - thick teal inset border */509+/* Resize mode active - thick inset border */
502 .resize-mode-active {510 .resize-mode-active {
503 background-color: transparent;511 background-color: transparent;
504 /* Prefer inset shadow, but also set outline for widgets that don't paint shadows */512 /* Prefer inset shadow, but also set outline for widgets that don't paint shadows */
505- box-shadow: inset 0 0 0 0.25em #00D4AA;513+ box-shadow: inset 0 0 0 0.25em var(--resize-mode-color);
506- outline: 0.25em solid #00D4AA;514+ outline: 0.25em solid var(--resize-mode-color);
507 outline-offset: -0.25em;515 outline-offset: -0.25em;
508 border-radius: 0;516 border-radius: 0;
509 }517 }
@@ -751,10 +759,10 @@ row:selected .session-manager-row {
751 font-family: var(--font-mono);759 font-family: var(--font-mono);
752 }760 }
753 761
754-/* Session mode border (purple) */762+/* Session mode border */
755 .session-mode-active {763 .session-mode-active {
756 background-color: transparent;764 background-color: transparent;
757- box-shadow: inset 0 0 0 0.25em #9B59B6;765+ box-shadow: inset 0 0 0 0.25em var(--session-mode-color);
758 border-radius: 0;766 border-radius: 0;
759 }767 }
760 `768 `
@@ -800,6 +808,32 @@ func generateToasterCSS(p Palette) string {
800 background-color: alpha(var(--destructive), 0.9);808 background-color: alpha(var(--destructive), 0.9);
801 color: var(--bg);809 color: var(--bg);
802 }810 }
811+
812+/* Toast with custom styling (mode indicator toasters) */
813+.toast-custom {
814+ color: #ffffff;
815+}
816+
817+/* Mode-specific toast colors */
818+.toast-pane-mode {
819+ background-color: var(--pane-mode-color);
820+ color: #ffffff;
821+}
822+
823+.toast-tab-mode {
824+ background-color: var(--tab-mode-color);
825+ color: #ffffff;
826+}
827+
828+.toast-session-mode {
829+ background-color: var(--session-mode-color);
830+ color: #ffffff;
831+}
832+
833+.toast-resize-mode {
834+ background-color: var(--resize-mode-color);
835+ color: #ffffff;
836+}
803 `837 `
804 }838 }
805 839
M internal/ui/theme/manager.go +30 -2
@@ -18,6 +18,7 @@ type Manager struct {
1818 darkPalette Palette // Dark theme colors
1919 uiScale float64 // UI scaling factor (1.0 = 100%)
2020 fonts FontConfig
21+ modeColors ModeColors // Modal mode indicator colors
2122 cssProvider *gtk.CssProvider
2223 }
2324
@@ -56,6 +57,9 @@ func NewManager(ctx context.Context, cfg *config.Config) *Manager {
5657 fonts.MonospaceFont = Coalesce(cfg.Appearance.MonospaceFont, fonts.MonospaceFont)
5758 }
5859
60+ // Build mode colors from config
61+ modeColors := modeColorsFromConfig(cfg)
62+
5963 log.Debug().
6064 Str("scheme", scheme).
6165 Bool("prefers_dark", prefersDark).
@@ -71,6 +75,22 @@ func NewManager(ctx context.Context, cfg *config.Config) *Manager {
7175 darkPalette: darkPalette,
7276 uiScale: uiScale,
7377 fonts: fonts,
78+ modeColors: modeColors,
79+ }
80+}
81+
82+// modeColorsFromConfig extracts mode colors from config, using defaults for missing values.
83+func modeColorsFromConfig(cfg *config.Config) ModeColors {
84+ defaults := DefaultModeColors()
85+ if cfg == nil {
86+ return defaults
87+ }
88+ styling := &cfg.Workspace.Styling
89+ return ModeColors{
90+ PaneMode: Coalesce(styling.PaneModeColor, defaults.PaneMode),
91+ TabMode: Coalesce(styling.TabModeColor, defaults.TabMode),
92+ SessionMode: Coalesce(styling.SessionModeColor, defaults.SessionMode),
93+ ResizeMode: Coalesce(styling.ResizeModeColor, defaults.ResizeMode),
7494 }
7595 }
7696
@@ -104,6 +124,11 @@ func (m *Manager) GetDarkPalette() Palette {
104124 return m.darkPalette
105125 }
106126
127+// GetModeColors returns the modal mode indicator colors.
128+func (m *Manager) GetModeColors() ModeColors {
129+ return m.modeColors
130+}
131+
107132 // GetWebUIThemeCSS returns CSS text that defines both light and dark variables.
108133 // WebUI uses `:root` for light and `.dark` overrides for dark.
109134 func (m *Manager) GetWebUIThemeCSS() string {
@@ -121,9 +146,9 @@ func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) {
121146 return
122147 }
123148
124- // Generate CSS with current palette, UI scale and fonts
149+ // Generate CSS with current palette, UI scale, fonts, and mode colors
125150 palette := m.GetCurrentPalette()
126- css := GenerateCSSWithScaleAndFonts(palette, m.uiScale, m.fonts)
151+ css := GenerateCSSFull(palette, m.uiScale, m.fonts, m.modeColors)
127152
128153 // Create CSS provider if needed
129154 if m.cssProvider == nil {
@@ -197,6 +222,9 @@ func (m *Manager) UpdateFromConfig(ctx context.Context, cfg *config.Config, disp
197222 MonospaceFont: Coalesce(cfg.Appearance.MonospaceFont, defaults.MonospaceFont),
198223 }
199224
225+ // Update mode colors
226+ m.modeColors = modeColorsFromConfig(cfg)
227+
200228 log.Info().
201229 Str("scheme", m.scheme).
202230 Bool("prefers_dark", m.prefersDark).
@@ -18,6 +18,7 @@ type Manager struct {
18 darkPalette Palette // Dark theme colors18 darkPalette Palette // Dark theme colors
19 uiScale float64 // UI scaling factor (1.0 = 100%)19 uiScale float64 // UI scaling factor (1.0 = 100%)
20 fonts FontConfig20 fonts FontConfig
21+ modeColors ModeColors // Modal mode indicator colors
21 cssProvider *gtk.CssProvider22 cssProvider *gtk.CssProvider
22 }23 }
23 24
@@ -56,6 +57,9 @@ func NewManager(ctx context.Context, cfg *config.Config) *Manager {
56 fonts.MonospaceFont = Coalesce(cfg.Appearance.MonospaceFont, fonts.MonospaceFont)57 fonts.MonospaceFont = Coalesce(cfg.Appearance.MonospaceFont, fonts.MonospaceFont)
57 }58 }
58 59
60+ // Build mode colors from config
61+ modeColors := modeColorsFromConfig(cfg)
62+
59 log.Debug().63 log.Debug().
60 Str("scheme", scheme).64 Str("scheme", scheme).
61 Bool("prefers_dark", prefersDark).65 Bool("prefers_dark", prefersDark).
@@ -71,6 +75,22 @@ func NewManager(ctx context.Context, cfg *config.Config) *Manager {
71 darkPalette: darkPalette,75 darkPalette: darkPalette,
72 uiScale: uiScale,76 uiScale: uiScale,
73 fonts: fonts,77 fonts: fonts,
78+ modeColors: modeColors,
79+ }
80+}
81+
82+// modeColorsFromConfig extracts mode colors from config, using defaults for missing values.
83+func modeColorsFromConfig(cfg *config.Config) ModeColors {
84+ defaults := DefaultModeColors()
85+ if cfg == nil {
86+ return defaults
87+ }
88+ styling := &cfg.Workspace.Styling
89+ return ModeColors{
90+ PaneMode: Coalesce(styling.PaneModeColor, defaults.PaneMode),
91+ TabMode: Coalesce(styling.TabModeColor, defaults.TabMode),
92+ SessionMode: Coalesce(styling.SessionModeColor, defaults.SessionMode),
93+ ResizeMode: Coalesce(styling.ResizeModeColor, defaults.ResizeMode),
74 }94 }
75 }95 }
76 96
@@ -104,6 +124,11 @@ func (m *Manager) GetDarkPalette() Palette {
104 return m.darkPalette124 return m.darkPalette
105 }125 }
106 126
127+// GetModeColors returns the modal mode indicator colors.
128+func (m *Manager) GetModeColors() ModeColors {
129+ return m.modeColors
130+}
131+
107 // GetWebUIThemeCSS returns CSS text that defines both light and dark variables.132 // GetWebUIThemeCSS returns CSS text that defines both light and dark variables.
108 // WebUI uses `:root` for light and `.dark` overrides for dark.133 // WebUI uses `:root` for light and `.dark` overrides for dark.
109 func (m *Manager) GetWebUIThemeCSS() string {134 func (m *Manager) GetWebUIThemeCSS() string {
@@ -121,9 +146,9 @@ func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) {
121 return146 return
122 }147 }
123 148
124- // Generate CSS with current palette, UI scale and fonts149+ // Generate CSS with current palette, UI scale, fonts, and mode colors
125 palette := m.GetCurrentPalette()150 palette := m.GetCurrentPalette()
126- css := GenerateCSSWithScaleAndFonts(palette, m.uiScale, m.fonts)151+ css := GenerateCSSFull(palette, m.uiScale, m.fonts, m.modeColors)
127 152
128 // Create CSS provider if needed153 // Create CSS provider if needed
129 if m.cssProvider == nil {154 if m.cssProvider == nil {
@@ -197,6 +222,9 @@ func (m *Manager) UpdateFromConfig(ctx context.Context, cfg *config.Config, disp
197 MonospaceFont: Coalesce(cfg.Appearance.MonospaceFont, defaults.MonospaceFont),222 MonospaceFont: Coalesce(cfg.Appearance.MonospaceFont, defaults.MonospaceFont),
198 }223 }
199 224
225+ // Update mode colors
226+ m.modeColors = modeColorsFromConfig(cfg)
227+
200 log.Info().228 log.Info().
201 Str("scheme", m.scheme).229 Str("scheme", m.scheme).
202 Bool("prefers_dark", m.prefersDark).230 Bool("prefers_dark", m.prefersDark).
M internal/ui/theme/palette.go +28 -0
@@ -24,6 +24,34 @@ type Palette struct {
2424 Destructive string // Error/destructive actions
2525 }
2626
27+// ModeColors holds colors for modal mode indicators (borders and toasters).
28+type ModeColors struct {
29+ PaneMode string // Color for pane mode (default: #4A90E2 blue)
30+ TabMode string // Color for tab mode (default: #FFA500 orange)
31+ SessionMode string // Color for session mode (default: #9B59B6 purple)
32+ ResizeMode string // Color for resize mode (default: #00D4AA teal)
33+}
34+
35+// DefaultModeColors returns the default mode indicator colors.
36+func DefaultModeColors() ModeColors {
37+ return ModeColors{
38+ PaneMode: "#4A90E2",
39+ TabMode: "#FFA500",
40+ SessionMode: "#9B59B6",
41+ ResizeMode: "#00D4AA",
42+ }
43+}
44+
45+// ToCSSVars generates CSS custom property declarations for mode colors.
46+func (m ModeColors) ToCSSVars() string {
47+ var sb strings.Builder
48+ sb.WriteString(" --pane-mode-color: " + m.PaneMode + ";\n")
49+ sb.WriteString(" --tab-mode-color: " + m.TabMode + ";\n")
50+ sb.WriteString(" --session-mode-color: " + m.SessionMode + ";\n")
51+ sb.WriteString(" --resize-mode-color: " + m.ResizeMode + ";\n")
52+ return sb.String()
53+}
54+
2755 // DefaultDarkPalette returns the default dark theme palette.
2856 func DefaultDarkPalette() Palette {
2957 return Palette{
@@ -24,6 +24,34 @@ type Palette struct {
24 Destructive string // Error/destructive actions24 Destructive string // Error/destructive actions
25 }25 }
26 26
27+// ModeColors holds colors for modal mode indicators (borders and toasters).
28+type ModeColors struct {
29+ PaneMode string // Color for pane mode (default: #4A90E2 blue)
30+ TabMode string // Color for tab mode (default: #FFA500 orange)
31+ SessionMode string // Color for session mode (default: #9B59B6 purple)
32+ ResizeMode string // Color for resize mode (default: #00D4AA teal)
33+}
34+
35+// DefaultModeColors returns the default mode indicator colors.
36+func DefaultModeColors() ModeColors {
37+ return ModeColors{
38+ PaneMode: "#4A90E2",
39+ TabMode: "#FFA500",
40+ SessionMode: "#9B59B6",
41+ ResizeMode: "#00D4AA",
42+ }
43+}
44+
45+// ToCSSVars generates CSS custom property declarations for mode colors.
46+func (m ModeColors) ToCSSVars() string {
47+ var sb strings.Builder
48+ sb.WriteString(" --pane-mode-color: " + m.PaneMode + ";\n")
49+ sb.WriteString(" --tab-mode-color: " + m.TabMode + ";\n")
50+ sb.WriteString(" --session-mode-color: " + m.SessionMode + ";\n")
51+ sb.WriteString(" --resize-mode-color: " + m.ResizeMode + ";\n")
52+ return sb.String()
53+}
54+
27 // DefaultDarkPalette returns the default dark theme palette.55 // DefaultDarkPalette returns the default dark theme palette.
28 func DefaultDarkPalette() Palette {56 func DefaultDarkPalette() Palette {
29 return Palette{57 return Palette{

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 8

Tags 42

↑↓ moveenter openesc close