detached e869777

Merge pull request #49 from bnema/feature/mode-indicator-toaster

feat: add mode indicator toaster with config migration improvements
commit
e869777608741c82c931a2cc8d4ddb83c54d8dbf
author
bnema · · 2025-12-28 08:14 UTC
committer
GitHub · · 2025-12-28 08:14 UTC
merge of
4e82c5d + 8a10c8e · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
23 files · +1681 -141
M .mockery.yaml +1 -0
@@ -10,6 +10,7 @@ packages:
1010 interfaces:
1111 ConfigMigrator: {}
1212 DatabaseProvider: {}
13+ DiffFormatter: {}
1314 SessionLogger: {}
1415 SessionSpawner: {}
1516 TabListProvider: {}
@@ -10,6 +10,7 @@ packages:
10 interfaces:10 interfaces:
11 ConfigMigrator: {}11 ConfigMigrator: {}
12 DatabaseProvider: {}12 DatabaseProvider: {}
13+ DiffFormatter: {}
13 SessionLogger: {}14 SessionLogger: {}
14 SessionSpawner: {}15 SessionSpawner: {}
15 TabListProvider: {}16 TabListProvider: {}
M CHANGELOG.md +2 -0
@@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
55 ## [Unreleased]
66
77 ### Added
8+- **Mode indicator toaster**: Visual notification showing current modal mode (PANE MODE, TAB MODE, SESSION MODE, RESIZE MODE) at bottom-left corner with mode-specific colors. Configurable via `workspace.styling.mode_indicator_toaster_enabled` (default: true).
89 - **Config migration system**: Detects missing config keys when new settings are added in updates and provides tools to add them.
910 - `dumber config status`: Shows config file path and number of new settings available.
1011 - `dumber config migrate`: Lists missing keys with types/defaults and adds them to config file.
@@ -35,6 +36,7 @@ All notable changes to this project will be documented in this file.
3536 - **Console logging**: Standardized console timestamps to `HH:MM:SS` across bootstrap and session logging.
3637
3738 ### Fixed
39+- **Config migration type matching**: Fixed migration incorrectly matching keys of different types (e.g., `_width` int keys with `_color` string keys) during rename detection, preventing value swapping.
3840 - **Fullscreen video tab bar**: Fixed tab bar remaining visible during fullscreen video playback. Now hides automatically when entering fullscreen and restores based on normal visibility logic when exiting.
3941 - **Progress bar stuck state**: Added 30-second timeout to progress bar to auto-hide if page load stalls and never completes.
4042 - **OAuth popup login**: Fixed parent page going blank after OAuth popup closes (e.g., Google login on claude.ai, notion.com). Related WebViews share a web process with their parent; destroying the popup was terminating the shared process, killing the parent. Now skips `TerminateWebProcess()` for popup WebViews.
@@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
5 ## [Unreleased]5 ## [Unreleased]
6 6
7 ### Added7 ### Added
8+- **Mode indicator toaster**: Visual notification showing current modal mode (PANE MODE, TAB MODE, SESSION MODE, RESIZE MODE) at bottom-left corner with mode-specific colors. Configurable via `workspace.styling.mode_indicator_toaster_enabled` (default: true).
8 - **Config migration system**: Detects missing config keys when new settings are added in updates and provides tools to add them.9 - **Config migration system**: Detects missing config keys when new settings are added in updates and provides tools to add them.
9 - `dumber config status`: Shows config file path and number of new settings available.10 - `dumber config status`: Shows config file path and number of new settings available.
10 - `dumber config migrate`: Lists missing keys with types/defaults and adds them to config file.11 - `dumber config migrate`: Lists missing keys with types/defaults and adds them to config file.
@@ -35,6 +36,7 @@ All notable changes to this project will be documented in this file.
35 - **Console logging**: Standardized console timestamps to `HH:MM:SS` across bootstrap and session logging.36 - **Console logging**: Standardized console timestamps to `HH:MM:SS` across bootstrap and session logging.
36 37
37 ### Fixed38 ### Fixed
39+- **Config migration type matching**: Fixed migration incorrectly matching keys of different types (e.g., `_width` int keys with `_color` string keys) during rename detection, preventing value swapping.
38 - **Fullscreen video tab bar**: Fixed tab bar remaining visible during fullscreen video playback. Now hides automatically when entering fullscreen and restores based on normal visibility logic when exiting.40 - **Fullscreen video tab bar**: Fixed tab bar remaining visible during fullscreen video playback. Now hides automatically when entering fullscreen and restores based on normal visibility logic when exiting.
39 - **Progress bar stuck state**: Added 30-second timeout to progress bar to auto-hide if page load stalls and never completes.41 - **Progress bar stuck state**: Added 30-second timeout to progress bar to auto-hide if page load stalls and never completes.
40 - **OAuth popup login**: Fixed parent page going blank after OAuth popup closes (e.g., Google login on claude.ai, notion.com). Related WebViews share a web process with their parent; destroying the popup was terminating the shared process, killing the parent. Now skips `TerminateWebProcess()` for popup WebViews.42 - **OAuth popup login**: Fixed parent page going blank after OAuth popup closes (e.g., Google login on claude.ai, notion.com). Related WebViews share a web process with their parent; destroying the popup was terminating the shared process, killing the parent. Now skips `TerminateWebProcess()` for popup WebViews.
M README.md +2 -2
@@ -307,7 +307,7 @@ Each tab holds its own workspace. Each workspace can contain multiple panes (spl
307307 3. **Navigate**: Use `Ctrl+Tab` / `Ctrl+Shift+Tab` for quick tab switching, or `L`/`H` in tab mode
308308 4. **Exit**: Press `Escape` to exit tab mode or wait for timeout
309309
310-Tab mode provides visual feedback with an orange border indicator.
310+
311311
312312 ## Pane Management
313313
@@ -335,7 +335,7 @@ Dumber automatically saves your browser session (tabs, panes, split ratios, URLs
335335 4. **Restore**: Select an exited session to restore it in a new browser window
336336 5. **CLI Access**: Use `dumber sessions` for an interactive TUI, or `dumber sessions list/restore/delete` for scripting
337337
338-Session mode provides visual feedback with a purple border indicator.
338+
339339
340340 ## Configuration
341341
@@ -307,7 +307,7 @@ Each tab holds its own workspace. Each workspace can contain multiple panes (spl
307 3. **Navigate**: Use `Ctrl+Tab` / `Ctrl+Shift+Tab` for quick tab switching, or `L`/`H` in tab mode307 3. **Navigate**: Use `Ctrl+Tab` / `Ctrl+Shift+Tab` for quick tab switching, or `L`/`H` in tab mode
308 4. **Exit**: Press `Escape` to exit tab mode or wait for timeout308 4. **Exit**: Press `Escape` to exit tab mode or wait for timeout
309 309
310-Tab mode provides visual feedback with an orange border indicator.310+
311 311
312 ## Pane Management312 ## Pane Management
313 313
@@ -335,7 +335,7 @@ Dumber automatically saves your browser session (tabs, panes, split ratios, URLs
335 4. **Restore**: Select an exited session to restore it in a new browser window335 4. **Restore**: Select an exited session to restore it in a new browser window
336 5. **CLI Access**: Use `dumber sessions` for an interactive TUI, or `dumber sessions list/restore/delete` for scripting336 5. **CLI Access**: Use `dumber sessions` for an interactive TUI, or `dumber sessions list/restore/delete` for scripting
337 337
338-Session mode provides visual feedback with a purple border indicator.338+
339 339
340 ## Configuration340 ## Configuration
341 341
M docs/CONFIG.md +20 -8
@@ -333,16 +333,28 @@ Notes:
333333 |-----|------|---------|-------------|
334334 | `workspace.styling.border_width` | int | `1` | Active pane border width (px) - overlay |
335335 | `workspace.styling.border_color` | string | `"@theme_selected_bg_color"` | Active pane border color |
336-| `workspace.styling.pane_mode_border_width` | int | `4` | Pane mode border width (px) - Ctrl+P N overlay |
337-| `workspace.styling.pane_mode_border_color` | string | `"#4A90E2"` | Pane mode border color (blue) |
338-| `workspace.styling.tab_mode_border_width` | int | `4` | Tab mode border width (px) - Ctrl+P T overlay |
339-| `workspace.styling.tab_mode_border_color` | string | `"#FFA500"` | Tab mode border color (orange) |
340-| `workspace.styling.session_mode_border_width` | int | `4` | Session mode border width (px) - Ctrl+O overlay |
341-| `workspace.styling.session_mode_border_color` | string | `"#9B59B6"` | Session mode border color (purple) |
342-| `workspace.styling.resize_mode_border_width` | int | `4` | Resize mode border width (px) |
343-| `workspace.styling.resize_mode_border_color` | string | `"#00D4AA"` | Resize mode border color (teal) |
336+| `workspace.styling.mode_border_width` | int | `4` | Modal mode border width (px) - applies to all modes |
337+| `workspace.styling.pane_mode_color` | string | `"#4A90E2"` | Pane mode color (blue) - used for border and toaster |
338+| `workspace.styling.tab_mode_color` | string | `"#FFA500"` | Tab mode color (orange) - used for border and toaster |
339+| `workspace.styling.session_mode_color` | string | `"#9B59B6"` | Session mode color (purple) - used for border and toaster |
340+| `workspace.styling.resize_mode_color` | string | `"#00D4AA"` | Resize mode color (teal) - used for border and toaster |
341+| `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | Show toaster notification when modal modes are active |
344342 | `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) |
345343
344+**Example:**
345+```toml
346+[workspace.styling]
347+border_width = 1
348+border_color = "@theme_selected_bg_color"
349+mode_border_width = 4
350+pane_mode_color = "#4A90E2" # Blue for pane mode
351+tab_mode_color = "#FFA500" # Orange for tab mode
352+session_mode_color = "#9B59B6" # Purple for session mode
353+resize_mode_color = "#00D4AA" # Teal for resize mode
354+mode_indicator_toaster_enabled = true
355+transition_duration = 120
356+```
357+
346358 ## Session
347359
348360 | Key | Type | Default | Description |
@@ -333,16 +333,28 @@ Notes:
333 |-----|------|---------|-------------|333 |-----|------|---------|-------------|
334 | `workspace.styling.border_width` | int | `1` | Active pane border width (px) - overlay |334 | `workspace.styling.border_width` | int | `1` | Active pane border width (px) - overlay |
335 | `workspace.styling.border_color` | string | `"@theme_selected_bg_color"` | Active pane border color |335 | `workspace.styling.border_color` | string | `"@theme_selected_bg_color"` | Active pane border color |
336-| `workspace.styling.pane_mode_border_width` | int | `4` | Pane mode border width (px) - Ctrl+P N overlay |336+| `workspace.styling.mode_border_width` | int | `4` | Modal mode border width (px) - applies to all modes |
337-| `workspace.styling.pane_mode_border_color` | string | `"#4A90E2"` | Pane mode border color (blue) |337+| `workspace.styling.pane_mode_color` | string | `"#4A90E2"` | Pane mode color (blue) - used for border and toaster |
338-| `workspace.styling.tab_mode_border_width` | int | `4` | Tab mode border width (px) - Ctrl+P T overlay |338+| `workspace.styling.tab_mode_color` | string | `"#FFA500"` | Tab mode color (orange) - used for border and toaster |
339-| `workspace.styling.tab_mode_border_color` | string | `"#FFA500"` | Tab mode border color (orange) |339+| `workspace.styling.session_mode_color` | string | `"#9B59B6"` | Session mode color (purple) - used for border and toaster |
340-| `workspace.styling.session_mode_border_width` | int | `4` | Session mode border width (px) - Ctrl+O overlay |340+| `workspace.styling.resize_mode_color` | string | `"#00D4AA"` | Resize mode color (teal) - used for border and toaster |
341-| `workspace.styling.session_mode_border_color` | string | `"#9B59B6"` | Session mode border color (purple) |341+| `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | Show toaster notification when modal modes are active |
342-| `workspace.styling.resize_mode_border_width` | int | `4` | Resize mode border width (px) |
343-| `workspace.styling.resize_mode_border_color` | string | `"#00D4AA"` | Resize mode border color (teal) |
344 | `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) |342 | `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) |
345 343
344+**Example:**
345+```toml
346+[workspace.styling]
347+border_width = 1
348+border_color = "@theme_selected_bg_color"
349+mode_border_width = 4
350+pane_mode_color = "#4A90E2" # Blue for pane mode
351+tab_mode_color = "#FFA500" # Orange for tab mode
352+session_mode_color = "#9B59B6" # Purple for session mode
353+resize_mode_color = "#00D4AA" # Teal for resize mode
354+mode_indicator_toaster_enabled = true
355+transition_duration = 120
356+```
357+
346 ## Session358 ## Session
347 359
348 | Key | Type | Default | Description |360 | Key | Type | Default | Description |
M internal/application/port/config_migrator.go +46 -1
@@ -18,16 +18,61 @@ 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+)
32+
33+// String returns a display symbol for the change type.
34+func (t KeyChangeType) String() string {
35+ switch t {
36+ case KeyChangeAdded:
37+ return "+"
38+ case KeyChangeRemoved:
39+ return "-"
40+ case KeyChangeRenamed:
41+ return "~"
42+ default:
43+ return "?"
44+ }
45+}
46+
47+// KeyChange represents a detected change between user config and defaults.
48+type KeyChange struct {
49+ Type KeyChangeType // Type of change
50+ OldKey string // Old key name (for removed/renamed)
51+ NewKey string // New key name (for added/renamed)
52+ OldValue string // Old value (for renamed/consolidated)
53+ NewValue string // New/default value
54+}
55+
2156 // ConfigMigrator checks for and applies config migrations.
2257 type ConfigMigrator interface {
2358 // CheckMigration checks if user config is missing any default keys.
2459 // Returns nil if no migration is needed (config file doesn't exist or is complete).
2560 CheckMigration() (*MigrationResult, error)
2661
62+ // DetectChanges analyzes user config and returns all detected changes.
63+ // This provides a detailed diff-like view of what migration would do.
64+ DetectChanges() ([]KeyChange, error)
65+
2766 // Migrate adds missing default keys to the user's config file.
28- // Returns the list of keys that were added.
67+ // Returns the list of keys that were added/renamed.
2968 Migrate() ([]string, error)
3069
3170 // GetKeyInfo returns detailed information about a config key.
3271 GetKeyInfo(key string) KeyInfo
3372 }
73+
74+// DiffFormatter formats config changes for display.
75+type DiffFormatter interface {
76+ // FormatChangesAsDiff returns changes formatted as a diff-like string for display.
77+ FormatChangesAsDiff(changes []KeyChange) string
78+}
@@ -18,16 +18,61 @@ 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+)
32+
33+// String returns a display symbol for the change type.
34+func (t KeyChangeType) String() string {
35+ switch t {
36+ case KeyChangeAdded:
37+ return "+"
38+ case KeyChangeRemoved:
39+ return "-"
40+ case KeyChangeRenamed:
41+ return "~"
42+ default:
43+ return "?"
44+ }
45+}
46+
47+// KeyChange represents a detected change between user config and defaults.
48+type KeyChange struct {
49+ Type KeyChangeType // Type of change
50+ OldKey string // Old key name (for removed/renamed)
51+ NewKey string // New key name (for added/renamed)
52+ OldValue string // Old value (for renamed/consolidated)
53+ NewValue string // New/default value
54+}
55+
21 // ConfigMigrator checks for and applies config migrations.56 // ConfigMigrator checks for and applies config migrations.
22 type ConfigMigrator interface {57 type ConfigMigrator interface {
23 // CheckMigration checks if user config is missing any default keys.58 // 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).59 // Returns nil if no migration is needed (config file doesn't exist or is complete).
25 CheckMigration() (*MigrationResult, error)60 CheckMigration() (*MigrationResult, error)
26 61
62+ // DetectChanges analyzes user config and returns all detected changes.
63+ // This provides a detailed diff-like view of what migration would do.
64+ DetectChanges() ([]KeyChange, error)
65+
27 // Migrate adds missing default keys to the user's config file.66 // Migrate adds missing default keys to the user's config file.
28- // Returns the list of keys that were added.67+ // Returns the list of keys that were added/renamed.
29 Migrate() ([]string, error)68 Migrate() ([]string, error)
30 69
31 // GetKeyInfo returns detailed information about a config key.70 // GetKeyInfo returns detailed information about a config key.
32 GetKeyInfo(key string) KeyInfo71 GetKeyInfo(key string) KeyInfo
33 }72 }
73+
74+// DiffFormatter formats config changes for display.
75+type DiffFormatter interface {
76+ // FormatChangesAsDiff returns changes formatted as a diff-like string for display.
77+ FormatChangesAsDiff(changes []KeyChange) string
78+}
M internal/application/port/mocks/mocks.go +133 -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)
@@ -202,6 +257,84 @@ func (_c *MockConfigMigrator_Migrate_Call) RunAndReturn(run func() ([]string, er
202257 return _c
203258 }
204259
260+// NewMockDiffFormatter creates a new instance of MockDiffFormatter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
261+// The first argument is typically a *testing.T value.
262+func NewMockDiffFormatter(t interface {
263+ mock.TestingT
264+ Cleanup(func())
265+}) *MockDiffFormatter {
266+ mock := &MockDiffFormatter{}
267+ mock.Mock.Test(t)
268+
269+ t.Cleanup(func() { mock.AssertExpectations(t) })
270+
271+ return mock
272+}
273+
274+// MockDiffFormatter is an autogenerated mock type for the DiffFormatter type
275+type MockDiffFormatter struct {
276+ mock.Mock
277+}
278+
279+type MockDiffFormatter_Expecter struct {
280+ mock *mock.Mock
281+}
282+
283+func (_m *MockDiffFormatter) EXPECT() *MockDiffFormatter_Expecter {
284+ return &MockDiffFormatter_Expecter{mock: &_m.Mock}
285+}
286+
287+// FormatChangesAsDiff provides a mock function for the type MockDiffFormatter
288+func (_mock *MockDiffFormatter) FormatChangesAsDiff(changes []port.KeyChange) string {
289+ ret := _mock.Called(changes)
290+
291+ if len(ret) == 0 {
292+ panic("no return value specified for FormatChangesAsDiff")
293+ }
294+
295+ var r0 string
296+ if returnFunc, ok := ret.Get(0).(func([]port.KeyChange) string); ok {
297+ r0 = returnFunc(changes)
298+ } else {
299+ r0 = ret.Get(0).(string)
300+ }
301+ return r0
302+}
303+
304+// MockDiffFormatter_FormatChangesAsDiff_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FormatChangesAsDiff'
305+type MockDiffFormatter_FormatChangesAsDiff_Call struct {
306+ *mock.Call
307+}
308+
309+// FormatChangesAsDiff is a helper method to define mock.On call
310+// - changes []port.KeyChange
311+func (_e *MockDiffFormatter_Expecter) FormatChangesAsDiff(changes interface{}) *MockDiffFormatter_FormatChangesAsDiff_Call {
312+ return &MockDiffFormatter_FormatChangesAsDiff_Call{Call: _e.mock.On("FormatChangesAsDiff", changes)}
313+}
314+
315+func (_c *MockDiffFormatter_FormatChangesAsDiff_Call) Run(run func(changes []port.KeyChange)) *MockDiffFormatter_FormatChangesAsDiff_Call {
316+ _c.Call.Run(func(args mock.Arguments) {
317+ var arg0 []port.KeyChange
318+ if args[0] != nil {
319+ arg0 = args[0].([]port.KeyChange)
320+ }
321+ run(
322+ arg0,
323+ )
324+ })
325+ return _c
326+}
327+
328+func (_c *MockDiffFormatter_FormatChangesAsDiff_Call) Return(s string) *MockDiffFormatter_FormatChangesAsDiff_Call {
329+ _c.Call.Return(s)
330+ return _c
331+}
332+
333+func (_c *MockDiffFormatter_FormatChangesAsDiff_Call) RunAndReturn(run func(changes []port.KeyChange) string) *MockDiffFormatter_FormatChangesAsDiff_Call {
334+ _c.Call.Return(run)
335+ return _c
336+}
337+
205338 // NewMockDatabaseProvider creates a new instance of MockDatabaseProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
206339 // The first argument is typically a *testing.T value.
207340 func NewMockDatabaseProvider(t interface {
@@ -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)
@@ -202,6 +257,84 @@ func (_c *MockConfigMigrator_Migrate_Call) RunAndReturn(run func() ([]string, er
202 return _c257 return _c
203 }258 }
204 259
260+// NewMockDiffFormatter creates a new instance of MockDiffFormatter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
261+// The first argument is typically a *testing.T value.
262+func NewMockDiffFormatter(t interface {
263+ mock.TestingT
264+ Cleanup(func())
265+}) *MockDiffFormatter {
266+ mock := &MockDiffFormatter{}
267+ mock.Mock.Test(t)
268+
269+ t.Cleanup(func() { mock.AssertExpectations(t) })
270+
271+ return mock
272+}
273+
274+// MockDiffFormatter is an autogenerated mock type for the DiffFormatter type
275+type MockDiffFormatter struct {
276+ mock.Mock
277+}
278+
279+type MockDiffFormatter_Expecter struct {
280+ mock *mock.Mock
281+}
282+
283+func (_m *MockDiffFormatter) EXPECT() *MockDiffFormatter_Expecter {
284+ return &MockDiffFormatter_Expecter{mock: &_m.Mock}
285+}
286+
287+// FormatChangesAsDiff provides a mock function for the type MockDiffFormatter
288+func (_mock *MockDiffFormatter) FormatChangesAsDiff(changes []port.KeyChange) string {
289+ ret := _mock.Called(changes)
290+
291+ if len(ret) == 0 {
292+ panic("no return value specified for FormatChangesAsDiff")
293+ }
294+
295+ var r0 string
296+ if returnFunc, ok := ret.Get(0).(func([]port.KeyChange) string); ok {
297+ r0 = returnFunc(changes)
298+ } else {
299+ r0 = ret.Get(0).(string)
300+ }
301+ return r0
302+}
303+
304+// MockDiffFormatter_FormatChangesAsDiff_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FormatChangesAsDiff'
305+type MockDiffFormatter_FormatChangesAsDiff_Call struct {
306+ *mock.Call
307+}
308+
309+// FormatChangesAsDiff is a helper method to define mock.On call
310+// - changes []port.KeyChange
311+func (_e *MockDiffFormatter_Expecter) FormatChangesAsDiff(changes interface{}) *MockDiffFormatter_FormatChangesAsDiff_Call {
312+ return &MockDiffFormatter_FormatChangesAsDiff_Call{Call: _e.mock.On("FormatChangesAsDiff", changes)}
313+}
314+
315+func (_c *MockDiffFormatter_FormatChangesAsDiff_Call) Run(run func(changes []port.KeyChange)) *MockDiffFormatter_FormatChangesAsDiff_Call {
316+ _c.Call.Run(func(args mock.Arguments) {
317+ var arg0 []port.KeyChange
318+ if args[0] != nil {
319+ arg0 = args[0].([]port.KeyChange)
320+ }
321+ run(
322+ arg0,
323+ )
324+ })
325+ return _c
326+}
327+
328+func (_c *MockDiffFormatter_FormatChangesAsDiff_Call) Return(s string) *MockDiffFormatter_FormatChangesAsDiff_Call {
329+ _c.Call.Return(s)
330+ return _c
331+}
332+
333+func (_c *MockDiffFormatter_FormatChangesAsDiff_Call) RunAndReturn(run func(changes []port.KeyChange) string) *MockDiffFormatter_FormatChangesAsDiff_Call {
334+ _c.Call.Return(run)
335+ return _c
336+}
337+
205 // NewMockDatabaseProvider creates a new instance of MockDatabaseProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.338 // NewMockDatabaseProvider creates a new instance of MockDatabaseProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
206 // The first argument is typically a *testing.T value.339 // The first argument is typically a *testing.T value.
207 func NewMockDatabaseProvider(t interface {340 func NewMockDatabaseProvider(t interface {
M internal/application/usecase/migrate_config.go +48 -3
@@ -20,6 +20,19 @@ type CheckConfigMigrationOutput struct {
2020 ConfigFile string
2121 }
2222
23+// DetectChangesInput holds the input for detecting config changes.
24+type DetectChangesInput struct{}
25+
26+// DetectChangesOutput holds the result of change detection.
27+type DetectChangesOutput struct {
28+ // HasChanges is true if any changes were detected.
29+ HasChanges bool
30+ // Changes contains all detected changes.
31+ Changes []port.KeyChange
32+ // DiffText is a formatted diff-like string representation.
33+ DiffText string
34+}
35+
2336 // MigrateConfigInput holds the input for migrating config.
2437 type MigrateConfigInput struct{}
2538
@@ -33,13 +46,15 @@ type MigrateConfigOutput struct {
3346
3447 // MigrateConfigUseCase handles config migration operations.
3548 type MigrateConfigUseCase struct {
36- migrator port.ConfigMigrator
49+ migrator port.ConfigMigrator
50+ diffFormatter port.DiffFormatter
3751 }
3852
3953 // NewMigrateConfigUseCase creates a new migrate config use case.
40-func NewMigrateConfigUseCase(migrator port.ConfigMigrator) *MigrateConfigUseCase {
54+func NewMigrateConfigUseCase(migrator port.ConfigMigrator, diffFormatter port.DiffFormatter) *MigrateConfigUseCase {
4155 return &MigrateConfigUseCase{
42- migrator: migrator,
56+ migrator: migrator,
57+ diffFormatter: diffFormatter,
4358 }
4459 }
4560
@@ -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: uc.diffFormatter.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)
@@ -20,6 +20,19 @@ type CheckConfigMigrationOutput struct {
20 ConfigFile string20 ConfigFile string
21 }21 }
22 22
23+// DetectChangesInput holds the input for detecting config changes.
24+type DetectChangesInput struct{}
25+
26+// DetectChangesOutput holds the result of change detection.
27+type DetectChangesOutput struct {
28+ // HasChanges is true if any changes were detected.
29+ HasChanges bool
30+ // Changes contains all detected changes.
31+ Changes []port.KeyChange
32+ // DiffText is a formatted diff-like string representation.
33+ DiffText string
34+}
35+
23 // MigrateConfigInput holds the input for migrating config.36 // MigrateConfigInput holds the input for migrating config.
24 type MigrateConfigInput struct{}37 type MigrateConfigInput struct{}
25 38
@@ -33,13 +46,15 @@ type MigrateConfigOutput struct {
33 46
34 // MigrateConfigUseCase handles config migration operations.47 // MigrateConfigUseCase handles config migration operations.
35 type MigrateConfigUseCase struct {48 type MigrateConfigUseCase struct {
36- migrator port.ConfigMigrator49+ migrator port.ConfigMigrator
50+ diffFormatter port.DiffFormatter
37 }51 }
38 52
39 // NewMigrateConfigUseCase creates a new migrate config use case.53 // NewMigrateConfigUseCase creates a new migrate config use case.
40-func NewMigrateConfigUseCase(migrator port.ConfigMigrator) *MigrateConfigUseCase {54+func NewMigrateConfigUseCase(migrator port.ConfigMigrator, diffFormatter port.DiffFormatter) *MigrateConfigUseCase {
41 return &MigrateConfigUseCase{55 return &MigrateConfigUseCase{
42- migrator: migrator,56+ migrator: migrator,
57+ diffFormatter: diffFormatter,
43 }58 }
44 }59 }
45 60
@@ -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: uc.diffFormatter.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)
M internal/application/usecase/migrate_config_test.go +176 -10
@@ -15,9 +15,10 @@ import (
1515
1616 func TestMigrateConfigUseCase_Check_NoMigrationNeeded(t *testing.T) {
1717 mockMigrator := mocks.NewMockConfigMigrator(t)
18+ mockFormatter := mocks.NewMockDiffFormatter(t)
1819 mockMigrator.EXPECT().CheckMigration().Return(nil, nil)
1920
20- uc := NewMigrateConfigUseCase(mockMigrator)
21+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
2122 ctx := context.Background()
2223
2324 result, err := uc.Check(ctx, CheckConfigMigrationInput{})
@@ -29,6 +30,7 @@ func TestMigrateConfigUseCase_Check_NoMigrationNeeded(t *testing.T) {
2930
3031 func TestMigrateConfigUseCase_Check_MigrationNeeded(t *testing.T) {
3132 mockMigrator := mocks.NewMockConfigMigrator(t)
33+ mockFormatter := mocks.NewMockDiffFormatter(t)
3234
3335 migrationResult := &port.MigrationResult{
3436 MissingKeys: []string{"key1", "key2"},
@@ -48,7 +50,7 @@ func TestMigrateConfigUseCase_Check_MigrationNeeded(t *testing.T) {
4850 DefaultValue: `"default"`,
4951 })
5052
51- uc := NewMigrateConfigUseCase(mockMigrator)
53+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
5254 ctx := context.Background()
5355
5456 result, err := uc.Check(ctx, CheckConfigMigrationInput{})
@@ -67,10 +69,11 @@ func TestMigrateConfigUseCase_Check_MigrationNeeded(t *testing.T) {
6769
6870 func TestMigrateConfigUseCase_Check_Error(t *testing.T) {
6971 mockMigrator := mocks.NewMockConfigMigrator(t)
72+ mockFormatter := mocks.NewMockDiffFormatter(t)
7073 expectedErr := errors.New("check failed")
7174 mockMigrator.EXPECT().CheckMigration().Return(nil, expectedErr)
7275
73- uc := NewMigrateConfigUseCase(mockMigrator)
76+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
7477 ctx := context.Background()
7578
7679 result, err := uc.Check(ctx, CheckConfigMigrationInput{})
@@ -82,9 +85,10 @@ func TestMigrateConfigUseCase_Check_Error(t *testing.T) {
8285
8386 func TestMigrateConfigUseCase_Execute_NoMigrationNeeded(t *testing.T) {
8487 mockMigrator := mocks.NewMockConfigMigrator(t)
88+ mockFormatter := mocks.NewMockDiffFormatter(t)
8589 mockMigrator.EXPECT().CheckMigration().Return(nil, nil)
8690
87- uc := NewMigrateConfigUseCase(mockMigrator)
91+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
8892 ctx := context.Background()
8993
9094 result, err := uc.Execute(ctx, MigrateConfigInput{})
@@ -95,6 +99,7 @@ func TestMigrateConfigUseCase_Execute_NoMigrationNeeded(t *testing.T) {
9599
96100 func TestMigrateConfigUseCase_Execute_Success(t *testing.T) {
97101 mockMigrator := mocks.NewMockConfigMigrator(t)
102+ mockFormatter := mocks.NewMockDiffFormatter(t)
98103
99104 migrationResult := &port.MigrationResult{
100105 MissingKeys: []string{"key1", "key2"},
@@ -103,7 +108,7 @@ func TestMigrateConfigUseCase_Execute_Success(t *testing.T) {
103108 mockMigrator.EXPECT().CheckMigration().Return(migrationResult, nil)
104109 mockMigrator.EXPECT().Migrate().Return([]string{"key1", "key2"}, nil)
105110
106- uc := NewMigrateConfigUseCase(mockMigrator)
111+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
107112 ctx := context.Background()
108113
109114 result, err := uc.Execute(ctx, MigrateConfigInput{})
@@ -115,6 +120,7 @@ func TestMigrateConfigUseCase_Execute_Success(t *testing.T) {
115120
116121 func TestMigrateConfigUseCase_Execute_MigrateError(t *testing.T) {
117122 mockMigrator := mocks.NewMockConfigMigrator(t)
123+ mockFormatter := mocks.NewMockDiffFormatter(t)
118124
119125 migrationResult := &port.MigrationResult{
120126 MissingKeys: []string{"key1"},
@@ -125,7 +131,7 @@ func TestMigrateConfigUseCase_Execute_MigrateError(t *testing.T) {
125131 expectedErr := errors.New("migrate failed")
126132 mockMigrator.EXPECT().Migrate().Return(nil, expectedErr)
127133
128- uc := NewMigrateConfigUseCase(mockMigrator)
134+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
129135 ctx := context.Background()
130136
131137 result, err := uc.Execute(ctx, MigrateConfigInput{})
@@ -137,11 +143,12 @@ func TestMigrateConfigUseCase_Execute_MigrateError(t *testing.T) {
137143
138144 func TestMigrateConfigUseCase_Execute_CheckError(t *testing.T) {
139145 mockMigrator := mocks.NewMockConfigMigrator(t)
146+ mockFormatter := mocks.NewMockDiffFormatter(t)
140147
141148 expectedErr := errors.New("check failed")
142149 mockMigrator.EXPECT().CheckMigration().Return(nil, expectedErr)
143150
144- uc := NewMigrateConfigUseCase(mockMigrator)
151+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
145152 ctx := context.Background()
146153
147154 result, err := uc.Execute(ctx, MigrateConfigInput{})
@@ -153,6 +160,7 @@ func TestMigrateConfigUseCase_Execute_CheckError(t *testing.T) {
153160
154161 func TestMigrateConfigUseCase_Check_EmptyMissingKeys(t *testing.T) {
155162 mockMigrator := mocks.NewMockConfigMigrator(t)
163+ mockFormatter := mocks.NewMockDiffFormatter(t)
156164
157165 // Return a result with empty missing keys
158166 migrationResult := &port.MigrationResult{
@@ -161,7 +169,7 @@ func TestMigrateConfigUseCase_Check_EmptyMissingKeys(t *testing.T) {
161169 }
162170 mockMigrator.EXPECT().CheckMigration().Return(migrationResult, nil)
163171
164- uc := NewMigrateConfigUseCase(mockMigrator)
172+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
165173 ctx := context.Background()
166174
167175 result, err := uc.Check(ctx, CheckConfigMigrationInput{})
@@ -173,15 +181,18 @@ func TestMigrateConfigUseCase_Check_EmptyMissingKeys(t *testing.T) {
173181
174182 func TestNewMigrateConfigUseCase(t *testing.T) {
175183 mockMigrator := mocks.NewMockConfigMigrator(t)
176- uc := NewMigrateConfigUseCase(mockMigrator)
184+ mockFormatter := mocks.NewMockDiffFormatter(t)
185+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
177186
178187 assert.NotNil(t, uc)
179188 assert.Equal(t, mockMigrator, uc.migrator)
189+ assert.Equal(t, mockFormatter, uc.diffFormatter)
180190 }
181191
182192 // Ensure mock expectations are set up correctly
183193 func TestMigrateConfigUseCase_MockExpectations(t *testing.T) {
184194 mockMigrator := mocks.NewMockConfigMigrator(t)
195+ mockFormatter := mocks.NewMockDiffFormatter(t)
185196
186197 // Set up expectations with mock.Anything for flexibility
187198 mockMigrator.On("CheckMigration").Return(&port.MigrationResult{
@@ -195,7 +206,7 @@ func TestMigrateConfigUseCase_MockExpectations(t *testing.T) {
195206 DefaultValue: `"test"`,
196207 }).Once()
197208
198- uc := NewMigrateConfigUseCase(mockMigrator)
209+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
199210 ctx := context.Background()
200211
201212 result, err := uc.Check(ctx, CheckConfigMigrationInput{})
@@ -204,3 +215,158 @@ func TestMigrateConfigUseCase_MockExpectations(t *testing.T) {
204215 assert.True(t, result.NeedsMigration)
205216 mockMigrator.AssertExpectations(t)
206217 }
218+
219+func TestMigrateConfigUseCase_DetectChanges_NoChanges(t *testing.T) {
220+ mockMigrator := mocks.NewMockConfigMigrator(t)
221+ mockFormatter := mocks.NewMockDiffFormatter(t)
222+ mockMigrator.EXPECT().DetectChanges().Return(nil, nil)
223+
224+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
225+ ctx := context.Background()
226+
227+ result, err := uc.DetectChanges(ctx, DetectChangesInput{})
228+
229+ require.NoError(t, err)
230+ assert.False(t, result.HasChanges)
231+ assert.Empty(t, result.Changes)
232+ assert.Equal(t, "No changes detected.", result.DiffText)
233+}
234+
235+func TestMigrateConfigUseCase_DetectChanges_WithAddedKey(t *testing.T) {
236+ mockMigrator := mocks.NewMockConfigMigrator(t)
237+ mockFormatter := mocks.NewMockDiffFormatter(t)
238+
239+ changes := []port.KeyChange{
240+ {
241+ Type: port.KeyChangeAdded,
242+ NewKey: "workspace.styling.mode_indicator_toaster_enabled",
243+ NewValue: "true",
244+ },
245+ }
246+ mockMigrator.EXPECT().DetectChanges().Return(changes, nil)
247+ mockFormatter.EXPECT().FormatChangesAsDiff(changes).Return("Config migration changes:\n\n + workspace.styling.mode_indicator_toaster_enabled = true\n")
248+
249+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
250+ ctx := context.Background()
251+
252+ result, err := uc.DetectChanges(ctx, DetectChangesInput{})
253+
254+ require.NoError(t, err)
255+ assert.True(t, result.HasChanges)
256+ assert.Len(t, result.Changes, 1)
257+ assert.Contains(t, result.DiffText, "+ workspace.styling.mode_indicator_toaster_enabled")
258+}
259+
260+func TestMigrateConfigUseCase_DetectChanges_WithRenamedKey(t *testing.T) {
261+ mockMigrator := mocks.NewMockConfigMigrator(t)
262+ mockFormatter := mocks.NewMockDiffFormatter(t)
263+
264+ changes := []port.KeyChange{
265+ {
266+ Type: port.KeyChangeRenamed,
267+ OldKey: "workspace.styling.pane_mode_border_color",
268+ NewKey: "workspace.styling.pane_mode_color",
269+ OldValue: `"#4A90E2"`,
270+ },
271+ }
272+ mockMigrator.EXPECT().DetectChanges().Return(changes, nil)
273+ mockFormatter.EXPECT().FormatChangesAsDiff(changes).Return("Config migration changes:\n\n ~ workspace.styling.pane_mode_border_color -> workspace.styling.pane_mode_color\n (value: \"#4A90E2\")\n")
274+
275+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
276+ ctx := context.Background()
277+
278+ result, err := uc.DetectChanges(ctx, DetectChangesInput{})
279+
280+ require.NoError(t, err)
281+ assert.True(t, result.HasChanges)
282+ assert.Len(t, result.Changes, 1)
283+ assert.Contains(t, result.DiffText, "~ workspace.styling.pane_mode_border_color -> workspace.styling.pane_mode_color")
284+}
285+
286+func TestMigrateConfigUseCase_DetectChanges_WithRemovedKey(t *testing.T) {
287+ mockMigrator := mocks.NewMockConfigMigrator(t)
288+ mockFormatter := mocks.NewMockDiffFormatter(t)
289+
290+ changes := []port.KeyChange{
291+ {
292+ Type: port.KeyChangeRemoved,
293+ OldKey: "workspace.styling.pane_mode_border_width",
294+ OldValue: "4",
295+ },
296+ }
297+ mockMigrator.EXPECT().DetectChanges().Return(changes, nil)
298+ mockFormatter.EXPECT().FormatChangesAsDiff(changes).Return("Config migration changes:\n\n - workspace.styling.pane_mode_border_width = 4 (deprecated)\n")
299+
300+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
301+ ctx := context.Background()
302+
303+ result, err := uc.DetectChanges(ctx, DetectChangesInput{})
304+
305+ require.NoError(t, err)
306+ assert.True(t, result.HasChanges)
307+ assert.Len(t, result.Changes, 1)
308+ assert.Contains(t, result.DiffText, "- workspace.styling.pane_mode_border_width")
309+ assert.Contains(t, result.DiffText, "(deprecated)")
310+}
311+
312+func TestMigrateConfigUseCase_DetectChanges_Error(t *testing.T) {
313+ mockMigrator := mocks.NewMockConfigMigrator(t)
314+ mockFormatter := mocks.NewMockDiffFormatter(t)
315+ expectedErr := errors.New("detect changes failed")
316+ mockMigrator.EXPECT().DetectChanges().Return(nil, expectedErr)
317+
318+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
319+ ctx := context.Background()
320+
321+ result, err := uc.DetectChanges(ctx, DetectChangesInput{})
322+
323+ require.Error(t, err)
324+ assert.Nil(t, result)
325+ assert.Equal(t, expectedErr, err)
326+}
327+
328+func TestMigrateConfigUseCase_DetectChanges_MultipleChanges(t *testing.T) {
329+ mockMigrator := mocks.NewMockConfigMigrator(t)
330+ mockFormatter := mocks.NewMockDiffFormatter(t)
331+
332+ changes := []port.KeyChange{
333+ {
334+ Type: port.KeyChangeAdded,
335+ NewKey: "workspace.styling.mode_border_width",
336+ NewValue: "4",
337+ },
338+ {
339+ Type: port.KeyChangeRenamed,
340+ OldKey: "workspace.styling.pane_mode_border_color",
341+ NewKey: "workspace.styling.pane_mode_color",
342+ OldValue: `"#4A90E2"`,
343+ },
344+ {
345+ Type: port.KeyChangeRemoved,
346+ OldKey: "workspace.styling.pane_mode_border_width",
347+ OldValue: "4",
348+ },
349+ }
350+ mockMigrator.EXPECT().DetectChanges().Return(changes, nil)
351+ mockFormatter.EXPECT().FormatChangesAsDiff(changes).Return(
352+ "Config migration changes:\n\n" +
353+ " + workspace.styling.mode_border_width = 4\n" +
354+ " ~ workspace.styling.pane_mode_border_color -> workspace.styling.pane_mode_color\n" +
355+ " (value: \"#4A90E2\")\n" +
356+ " - workspace.styling.pane_mode_border_width = 4 (deprecated)\n",
357+ )
358+
359+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
360+ ctx := context.Background()
361+
362+ result, err := uc.DetectChanges(ctx, DetectChangesInput{})
363+
364+ require.NoError(t, err)
365+ assert.True(t, result.HasChanges)
366+ assert.Len(t, result.Changes, 3)
367+
368+ // Verify diff contains all change types
369+ assert.Contains(t, result.DiffText, "+ workspace.styling.mode_border_width")
370+ assert.Contains(t, result.DiffText, "~ workspace.styling.pane_mode_border_color")
371+ assert.Contains(t, result.DiffText, "- workspace.styling.pane_mode_border_width")
372+}
@@ -15,9 +15,10 @@ import (
15 15
16 func TestMigrateConfigUseCase_Check_NoMigrationNeeded(t *testing.T) {16 func TestMigrateConfigUseCase_Check_NoMigrationNeeded(t *testing.T) {
17 mockMigrator := mocks.NewMockConfigMigrator(t)17 mockMigrator := mocks.NewMockConfigMigrator(t)
18+ mockFormatter := mocks.NewMockDiffFormatter(t)
18 mockMigrator.EXPECT().CheckMigration().Return(nil, nil)19 mockMigrator.EXPECT().CheckMigration().Return(nil, nil)
19 20
20- uc := NewMigrateConfigUseCase(mockMigrator)21+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
21 ctx := context.Background()22 ctx := context.Background()
22 23
23 result, err := uc.Check(ctx, CheckConfigMigrationInput{})24 result, err := uc.Check(ctx, CheckConfigMigrationInput{})
@@ -29,6 +30,7 @@ func TestMigrateConfigUseCase_Check_NoMigrationNeeded(t *testing.T) {
29 30
30 func TestMigrateConfigUseCase_Check_MigrationNeeded(t *testing.T) {31 func TestMigrateConfigUseCase_Check_MigrationNeeded(t *testing.T) {
31 mockMigrator := mocks.NewMockConfigMigrator(t)32 mockMigrator := mocks.NewMockConfigMigrator(t)
33+ mockFormatter := mocks.NewMockDiffFormatter(t)
32 34
33 migrationResult := &port.MigrationResult{35 migrationResult := &port.MigrationResult{
34 MissingKeys: []string{"key1", "key2"},36 MissingKeys: []string{"key1", "key2"},
@@ -48,7 +50,7 @@ func TestMigrateConfigUseCase_Check_MigrationNeeded(t *testing.T) {
48 DefaultValue: `"default"`,50 DefaultValue: `"default"`,
49 })51 })
50 52
51- uc := NewMigrateConfigUseCase(mockMigrator)53+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
52 ctx := context.Background()54 ctx := context.Background()
53 55
54 result, err := uc.Check(ctx, CheckConfigMigrationInput{})56 result, err := uc.Check(ctx, CheckConfigMigrationInput{})
@@ -67,10 +69,11 @@ func TestMigrateConfigUseCase_Check_MigrationNeeded(t *testing.T) {
67 69
68 func TestMigrateConfigUseCase_Check_Error(t *testing.T) {70 func TestMigrateConfigUseCase_Check_Error(t *testing.T) {
69 mockMigrator := mocks.NewMockConfigMigrator(t)71 mockMigrator := mocks.NewMockConfigMigrator(t)
72+ mockFormatter := mocks.NewMockDiffFormatter(t)
70 expectedErr := errors.New("check failed")73 expectedErr := errors.New("check failed")
71 mockMigrator.EXPECT().CheckMigration().Return(nil, expectedErr)74 mockMigrator.EXPECT().CheckMigration().Return(nil, expectedErr)
72 75
73- uc := NewMigrateConfigUseCase(mockMigrator)76+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
74 ctx := context.Background()77 ctx := context.Background()
75 78
76 result, err := uc.Check(ctx, CheckConfigMigrationInput{})79 result, err := uc.Check(ctx, CheckConfigMigrationInput{})
@@ -82,9 +85,10 @@ func TestMigrateConfigUseCase_Check_Error(t *testing.T) {
82 85
83 func TestMigrateConfigUseCase_Execute_NoMigrationNeeded(t *testing.T) {86 func TestMigrateConfigUseCase_Execute_NoMigrationNeeded(t *testing.T) {
84 mockMigrator := mocks.NewMockConfigMigrator(t)87 mockMigrator := mocks.NewMockConfigMigrator(t)
88+ mockFormatter := mocks.NewMockDiffFormatter(t)
85 mockMigrator.EXPECT().CheckMigration().Return(nil, nil)89 mockMigrator.EXPECT().CheckMigration().Return(nil, nil)
86 90
87- uc := NewMigrateConfigUseCase(mockMigrator)91+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
88 ctx := context.Background()92 ctx := context.Background()
89 93
90 result, err := uc.Execute(ctx, MigrateConfigInput{})94 result, err := uc.Execute(ctx, MigrateConfigInput{})
@@ -95,6 +99,7 @@ func TestMigrateConfigUseCase_Execute_NoMigrationNeeded(t *testing.T) {
95 99
96 func TestMigrateConfigUseCase_Execute_Success(t *testing.T) {100 func TestMigrateConfigUseCase_Execute_Success(t *testing.T) {
97 mockMigrator := mocks.NewMockConfigMigrator(t)101 mockMigrator := mocks.NewMockConfigMigrator(t)
102+ mockFormatter := mocks.NewMockDiffFormatter(t)
98 103
99 migrationResult := &port.MigrationResult{104 migrationResult := &port.MigrationResult{
100 MissingKeys: []string{"key1", "key2"},105 MissingKeys: []string{"key1", "key2"},
@@ -103,7 +108,7 @@ func TestMigrateConfigUseCase_Execute_Success(t *testing.T) {
103 mockMigrator.EXPECT().CheckMigration().Return(migrationResult, nil)108 mockMigrator.EXPECT().CheckMigration().Return(migrationResult, nil)
104 mockMigrator.EXPECT().Migrate().Return([]string{"key1", "key2"}, nil)109 mockMigrator.EXPECT().Migrate().Return([]string{"key1", "key2"}, nil)
105 110
106- uc := NewMigrateConfigUseCase(mockMigrator)111+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
107 ctx := context.Background()112 ctx := context.Background()
108 113
109 result, err := uc.Execute(ctx, MigrateConfigInput{})114 result, err := uc.Execute(ctx, MigrateConfigInput{})
@@ -115,6 +120,7 @@ func TestMigrateConfigUseCase_Execute_Success(t *testing.T) {
115 120
116 func TestMigrateConfigUseCase_Execute_MigrateError(t *testing.T) {121 func TestMigrateConfigUseCase_Execute_MigrateError(t *testing.T) {
117 mockMigrator := mocks.NewMockConfigMigrator(t)122 mockMigrator := mocks.NewMockConfigMigrator(t)
123+ mockFormatter := mocks.NewMockDiffFormatter(t)
118 124
119 migrationResult := &port.MigrationResult{125 migrationResult := &port.MigrationResult{
120 MissingKeys: []string{"key1"},126 MissingKeys: []string{"key1"},
@@ -125,7 +131,7 @@ func TestMigrateConfigUseCase_Execute_MigrateError(t *testing.T) {
125 expectedErr := errors.New("migrate failed")131 expectedErr := errors.New("migrate failed")
126 mockMigrator.EXPECT().Migrate().Return(nil, expectedErr)132 mockMigrator.EXPECT().Migrate().Return(nil, expectedErr)
127 133
128- uc := NewMigrateConfigUseCase(mockMigrator)134+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
129 ctx := context.Background()135 ctx := context.Background()
130 136
131 result, err := uc.Execute(ctx, MigrateConfigInput{})137 result, err := uc.Execute(ctx, MigrateConfigInput{})
@@ -137,11 +143,12 @@ func TestMigrateConfigUseCase_Execute_MigrateError(t *testing.T) {
137 143
138 func TestMigrateConfigUseCase_Execute_CheckError(t *testing.T) {144 func TestMigrateConfigUseCase_Execute_CheckError(t *testing.T) {
139 mockMigrator := mocks.NewMockConfigMigrator(t)145 mockMigrator := mocks.NewMockConfigMigrator(t)
146+ mockFormatter := mocks.NewMockDiffFormatter(t)
140 147
141 expectedErr := errors.New("check failed")148 expectedErr := errors.New("check failed")
142 mockMigrator.EXPECT().CheckMigration().Return(nil, expectedErr)149 mockMigrator.EXPECT().CheckMigration().Return(nil, expectedErr)
143 150
144- uc := NewMigrateConfigUseCase(mockMigrator)151+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
145 ctx := context.Background()152 ctx := context.Background()
146 153
147 result, err := uc.Execute(ctx, MigrateConfigInput{})154 result, err := uc.Execute(ctx, MigrateConfigInput{})
@@ -153,6 +160,7 @@ func TestMigrateConfigUseCase_Execute_CheckError(t *testing.T) {
153 160
154 func TestMigrateConfigUseCase_Check_EmptyMissingKeys(t *testing.T) {161 func TestMigrateConfigUseCase_Check_EmptyMissingKeys(t *testing.T) {
155 mockMigrator := mocks.NewMockConfigMigrator(t)162 mockMigrator := mocks.NewMockConfigMigrator(t)
163+ mockFormatter := mocks.NewMockDiffFormatter(t)
156 164
157 // Return a result with empty missing keys165 // Return a result with empty missing keys
158 migrationResult := &port.MigrationResult{166 migrationResult := &port.MigrationResult{
@@ -161,7 +169,7 @@ func TestMigrateConfigUseCase_Check_EmptyMissingKeys(t *testing.T) {
161 }169 }
162 mockMigrator.EXPECT().CheckMigration().Return(migrationResult, nil)170 mockMigrator.EXPECT().CheckMigration().Return(migrationResult, nil)
163 171
164- uc := NewMigrateConfigUseCase(mockMigrator)172+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
165 ctx := context.Background()173 ctx := context.Background()
166 174
167 result, err := uc.Check(ctx, CheckConfigMigrationInput{})175 result, err := uc.Check(ctx, CheckConfigMigrationInput{})
@@ -173,15 +181,18 @@ func TestMigrateConfigUseCase_Check_EmptyMissingKeys(t *testing.T) {
173 181
174 func TestNewMigrateConfigUseCase(t *testing.T) {182 func TestNewMigrateConfigUseCase(t *testing.T) {
175 mockMigrator := mocks.NewMockConfigMigrator(t)183 mockMigrator := mocks.NewMockConfigMigrator(t)
176- uc := NewMigrateConfigUseCase(mockMigrator)184+ mockFormatter := mocks.NewMockDiffFormatter(t)
185+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
177 186
178 assert.NotNil(t, uc)187 assert.NotNil(t, uc)
179 assert.Equal(t, mockMigrator, uc.migrator)188 assert.Equal(t, mockMigrator, uc.migrator)
189+ assert.Equal(t, mockFormatter, uc.diffFormatter)
180 }190 }
181 191
182 // Ensure mock expectations are set up correctly192 // Ensure mock expectations are set up correctly
183 func TestMigrateConfigUseCase_MockExpectations(t *testing.T) {193 func TestMigrateConfigUseCase_MockExpectations(t *testing.T) {
184 mockMigrator := mocks.NewMockConfigMigrator(t)194 mockMigrator := mocks.NewMockConfigMigrator(t)
195+ mockFormatter := mocks.NewMockDiffFormatter(t)
185 196
186 // Set up expectations with mock.Anything for flexibility197 // Set up expectations with mock.Anything for flexibility
187 mockMigrator.On("CheckMigration").Return(&port.MigrationResult{198 mockMigrator.On("CheckMigration").Return(&port.MigrationResult{
@@ -195,7 +206,7 @@ func TestMigrateConfigUseCase_MockExpectations(t *testing.T) {
195 DefaultValue: `"test"`,206 DefaultValue: `"test"`,
196 }).Once()207 }).Once()
197 208
198- uc := NewMigrateConfigUseCase(mockMigrator)209+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
199 ctx := context.Background()210 ctx := context.Background()
200 211
201 result, err := uc.Check(ctx, CheckConfigMigrationInput{})212 result, err := uc.Check(ctx, CheckConfigMigrationInput{})
@@ -204,3 +215,158 @@ func TestMigrateConfigUseCase_MockExpectations(t *testing.T) {
204 assert.True(t, result.NeedsMigration)215 assert.True(t, result.NeedsMigration)
205 mockMigrator.AssertExpectations(t)216 mockMigrator.AssertExpectations(t)
206 }217 }
218+
219+func TestMigrateConfigUseCase_DetectChanges_NoChanges(t *testing.T) {
220+ mockMigrator := mocks.NewMockConfigMigrator(t)
221+ mockFormatter := mocks.NewMockDiffFormatter(t)
222+ mockMigrator.EXPECT().DetectChanges().Return(nil, nil)
223+
224+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
225+ ctx := context.Background()
226+
227+ result, err := uc.DetectChanges(ctx, DetectChangesInput{})
228+
229+ require.NoError(t, err)
230+ assert.False(t, result.HasChanges)
231+ assert.Empty(t, result.Changes)
232+ assert.Equal(t, "No changes detected.", result.DiffText)
233+}
234+
235+func TestMigrateConfigUseCase_DetectChanges_WithAddedKey(t *testing.T) {
236+ mockMigrator := mocks.NewMockConfigMigrator(t)
237+ mockFormatter := mocks.NewMockDiffFormatter(t)
238+
239+ changes := []port.KeyChange{
240+ {
241+ Type: port.KeyChangeAdded,
242+ NewKey: "workspace.styling.mode_indicator_toaster_enabled",
243+ NewValue: "true",
244+ },
245+ }
246+ mockMigrator.EXPECT().DetectChanges().Return(changes, nil)
247+ mockFormatter.EXPECT().FormatChangesAsDiff(changes).Return("Config migration changes:\n\n + workspace.styling.mode_indicator_toaster_enabled = true\n")
248+
249+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
250+ ctx := context.Background()
251+
252+ result, err := uc.DetectChanges(ctx, DetectChangesInput{})
253+
254+ require.NoError(t, err)
255+ assert.True(t, result.HasChanges)
256+ assert.Len(t, result.Changes, 1)
257+ assert.Contains(t, result.DiffText, "+ workspace.styling.mode_indicator_toaster_enabled")
258+}
259+
260+func TestMigrateConfigUseCase_DetectChanges_WithRenamedKey(t *testing.T) {
261+ mockMigrator := mocks.NewMockConfigMigrator(t)
262+ mockFormatter := mocks.NewMockDiffFormatter(t)
263+
264+ changes := []port.KeyChange{
265+ {
266+ Type: port.KeyChangeRenamed,
267+ OldKey: "workspace.styling.pane_mode_border_color",
268+ NewKey: "workspace.styling.pane_mode_color",
269+ OldValue: `"#4A90E2"`,
270+ },
271+ }
272+ mockMigrator.EXPECT().DetectChanges().Return(changes, nil)
273+ mockFormatter.EXPECT().FormatChangesAsDiff(changes).Return("Config migration changes:\n\n ~ workspace.styling.pane_mode_border_color -> workspace.styling.pane_mode_color\n (value: \"#4A90E2\")\n")
274+
275+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
276+ ctx := context.Background()
277+
278+ result, err := uc.DetectChanges(ctx, DetectChangesInput{})
279+
280+ require.NoError(t, err)
281+ assert.True(t, result.HasChanges)
282+ assert.Len(t, result.Changes, 1)
283+ assert.Contains(t, result.DiffText, "~ workspace.styling.pane_mode_border_color -> workspace.styling.pane_mode_color")
284+}
285+
286+func TestMigrateConfigUseCase_DetectChanges_WithRemovedKey(t *testing.T) {
287+ mockMigrator := mocks.NewMockConfigMigrator(t)
288+ mockFormatter := mocks.NewMockDiffFormatter(t)
289+
290+ changes := []port.KeyChange{
291+ {
292+ Type: port.KeyChangeRemoved,
293+ OldKey: "workspace.styling.pane_mode_border_width",
294+ OldValue: "4",
295+ },
296+ }
297+ mockMigrator.EXPECT().DetectChanges().Return(changes, nil)
298+ mockFormatter.EXPECT().FormatChangesAsDiff(changes).Return("Config migration changes:\n\n - workspace.styling.pane_mode_border_width = 4 (deprecated)\n")
299+
300+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
301+ ctx := context.Background()
302+
303+ result, err := uc.DetectChanges(ctx, DetectChangesInput{})
304+
305+ require.NoError(t, err)
306+ assert.True(t, result.HasChanges)
307+ assert.Len(t, result.Changes, 1)
308+ assert.Contains(t, result.DiffText, "- workspace.styling.pane_mode_border_width")
309+ assert.Contains(t, result.DiffText, "(deprecated)")
310+}
311+
312+func TestMigrateConfigUseCase_DetectChanges_Error(t *testing.T) {
313+ mockMigrator := mocks.NewMockConfigMigrator(t)
314+ mockFormatter := mocks.NewMockDiffFormatter(t)
315+ expectedErr := errors.New("detect changes failed")
316+ mockMigrator.EXPECT().DetectChanges().Return(nil, expectedErr)
317+
318+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
319+ ctx := context.Background()
320+
321+ result, err := uc.DetectChanges(ctx, DetectChangesInput{})
322+
323+ require.Error(t, err)
324+ assert.Nil(t, result)
325+ assert.Equal(t, expectedErr, err)
326+}
327+
328+func TestMigrateConfigUseCase_DetectChanges_MultipleChanges(t *testing.T) {
329+ mockMigrator := mocks.NewMockConfigMigrator(t)
330+ mockFormatter := mocks.NewMockDiffFormatter(t)
331+
332+ changes := []port.KeyChange{
333+ {
334+ Type: port.KeyChangeAdded,
335+ NewKey: "workspace.styling.mode_border_width",
336+ NewValue: "4",
337+ },
338+ {
339+ Type: port.KeyChangeRenamed,
340+ OldKey: "workspace.styling.pane_mode_border_color",
341+ NewKey: "workspace.styling.pane_mode_color",
342+ OldValue: `"#4A90E2"`,
343+ },
344+ {
345+ Type: port.KeyChangeRemoved,
346+ OldKey: "workspace.styling.pane_mode_border_width",
347+ OldValue: "4",
348+ },
349+ }
350+ mockMigrator.EXPECT().DetectChanges().Return(changes, nil)
351+ mockFormatter.EXPECT().FormatChangesAsDiff(changes).Return(
352+ "Config migration changes:\n\n" +
353+ " + workspace.styling.mode_border_width = 4\n" +
354+ " ~ workspace.styling.pane_mode_border_color -> workspace.styling.pane_mode_color\n" +
355+ " (value: \"#4A90E2\")\n" +
356+ " - workspace.styling.pane_mode_border_width = 4 (deprecated)\n",
357+ )
358+
359+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
360+ ctx := context.Background()
361+
362+ result, err := uc.DetectChanges(ctx, DetectChangesInput{})
363+
364+ require.NoError(t, err)
365+ assert.True(t, result.HasChanges)
366+ assert.Len(t, result.Changes, 3)
367+
368+ // Verify diff contains all change types
369+ assert.Contains(t, result.DiffText, "+ workspace.styling.mode_border_width")
370+ assert.Contains(t, result.DiffText, "~ workspace.styling.pane_mode_border_color")
371+ assert.Contains(t, result.DiffText, "- workspace.styling.pane_mode_border_width")
372+}
M internal/cli/cmd/config.go +28 -25
@@ -56,7 +56,8 @@ func runConfigStatus(_ *cobra.Command, _ []string) error {
5656
5757 renderer := styles.NewConfigRenderer(app.Theme)
5858 migrator := config.NewMigrator()
59- uc := usecase.NewMigrateConfigUseCase(migrator)
59+ diffFormatter := config.NewDiffFormatter()
60+ uc := usecase.NewMigrateConfigUseCase(migrator, diffFormatter)
6061
6162 // Get config file path
6263 configFile, err := config.GetConfigFile()
@@ -100,7 +101,8 @@ func runConfigMigrate(_ *cobra.Command, _ []string) error {
100101
101102 renderer := styles.NewConfigRenderer(app.Theme)
102103 migrator := config.NewMigrator()
103- uc := usecase.NewMigrateConfigUseCase(migrator)
104+ diffFormatter := config.NewDiffFormatter()
105+ uc := usecase.NewMigrateConfigUseCase(migrator, diffFormatter)
104106
105107 // Get config file path
106108 configFile, err := config.GetConfigFile()
@@ -115,22 +117,23 @@ func runConfigMigrate(_ *cobra.Command, _ []string) error {
115117 return nil
116118 }
117119
118- // Check for migration
120+ // Detect changes (includes renames, additions, and removals)
119121 ctx := context.Background()
120- result, err := uc.Check(ctx, usecase.CheckConfigMigrationInput{})
122+ detectResult, err := uc.DetectChanges(ctx, usecase.DetectChangesInput{})
121123 if err != nil {
122124 fmt.Println(renderer.RenderError(err))
123125 return nil
124126 }
125127
126- if !result.NeedsMigration {
128+ if !detectResult.HasChanges {
127129 fmt.Println(renderer.RenderUpToDate(configFile))
128130 return nil
129131 }
130132
131- // Show config info and missing keys
132- fmt.Println(renderer.RenderConfigInfo(configFile, len(result.MissingKeys)))
133- fmt.Println(renderer.RenderMissingKeys(result.MissingKeys))
133+ // Show config info with change summary
134+ fmt.Println(renderer.RenderConfigInfo(configFile, len(detectResult.Changes)))
135+ fmt.Println(renderer.RenderChangesSummary(detectResult.Changes))
136+ fmt.Println(renderer.RenderChanges(detectResult.Changes))
134137
135138 // If --yes flag, proceed without confirmation
136139 if configYes {
@@ -138,7 +141,7 @@ func runConfigMigrate(_ *cobra.Command, _ []string) error {
138141 }
139142
140143 // Show confirmation dialog
141- return runMigrateWithConfirmation(ctx, uc, renderer, app.Theme, result.MissingKeys)
144+ return runMigrateWithConfirmation(ctx, uc, renderer, app.Theme, detectResult.Changes)
142145 }
143146
144147 // executeMigration performs the actual migration.
@@ -166,12 +169,12 @@ const (
166169
167170 // migrateModel is the bubbletea model for the migrate confirmation.
168171 type migrateModel struct {
169- spinner spinner.Model
170- renderer *styles.ConfigRenderer
171- confirm styles.ConfirmModel
172- state migrateState
173- uc *usecase.MigrateConfigUseCase
174- missingKeys []port.KeyInfo
172+ spinner spinner.Model
173+ renderer *styles.ConfigRenderer
174+ confirm styles.ConfirmModel
175+ state migrateState
176+ uc *usecase.MigrateConfigUseCase
177+ changes []port.KeyChange
175178
176179 result string
177180 err error
@@ -188,21 +191,21 @@ func newMigrateModel(
188191 renderer *styles.ConfigRenderer,
189192 theme *styles.Theme,
190193 uc *usecase.MigrateConfigUseCase,
191- missingKeys []port.KeyInfo,
194+ changes []port.KeyChange,
192195 ) migrateModel {
193196 s := spinner.New()
194197 s.Spinner = spinner.Dot
195198 s.Style = lipgloss.NewStyle().Foreground(theme.Accent)
196199
197- confirm := styles.NewConfirm(theme, "Add these settings with default values?")
200+ confirm := styles.NewConfirm(theme, "Apply these changes?")
198201
199202 return migrateModel{
200- spinner: s,
201- renderer: renderer,
202- confirm: confirm,
203- state: migrateStateConfirm,
204- uc: uc,
205- missingKeys: missingKeys,
203+ spinner: s,
204+ renderer: renderer,
205+ confirm: confirm,
206+ state: migrateStateConfirm,
207+ uc: uc,
208+ changes: changes,
206209 }
207210 }
208211
@@ -289,12 +292,12 @@ func runMigrateWithConfirmation(
289292 uc *usecase.MigrateConfigUseCase,
290293 renderer *styles.ConfigRenderer,
291294 theme *styles.Theme,
292- missingKeys []port.KeyInfo,
295+ changes []port.KeyChange,
293296 ) error {
294297 // Suppress unused ctx warning - kept for consistency with other run functions
295298 _ = ctx
296299
297- m := newMigrateModel(renderer, theme, uc, missingKeys)
300+ m := newMigrateModel(renderer, theme, uc, changes)
298301 p := tea.NewProgram(m)
299302
300303 _, err := p.Run()
@@ -56,7 +56,8 @@ func runConfigStatus(_ *cobra.Command, _ []string) error {
56 56
57 renderer := styles.NewConfigRenderer(app.Theme)57 renderer := styles.NewConfigRenderer(app.Theme)
58 migrator := config.NewMigrator()58 migrator := config.NewMigrator()
59- uc := usecase.NewMigrateConfigUseCase(migrator)59+ diffFormatter := config.NewDiffFormatter()
60+ uc := usecase.NewMigrateConfigUseCase(migrator, diffFormatter)
60 61
61 // Get config file path62 // Get config file path
62 configFile, err := config.GetConfigFile()63 configFile, err := config.GetConfigFile()
@@ -100,7 +101,8 @@ func runConfigMigrate(_ *cobra.Command, _ []string) error {
100 101
101 renderer := styles.NewConfigRenderer(app.Theme)102 renderer := styles.NewConfigRenderer(app.Theme)
102 migrator := config.NewMigrator()103 migrator := config.NewMigrator()
103- uc := usecase.NewMigrateConfigUseCase(migrator)104+ diffFormatter := config.NewDiffFormatter()
105+ uc := usecase.NewMigrateConfigUseCase(migrator, diffFormatter)
104 106
105 // Get config file path107 // Get config file path
106 configFile, err := config.GetConfigFile()108 configFile, err := config.GetConfigFile()
@@ -115,22 +117,23 @@ func runConfigMigrate(_ *cobra.Command, _ []string) error {
115 return nil117 return nil
116 }118 }
117 119
118- // Check for migration120+ // Detect changes (includes renames, additions, and removals)
119 ctx := context.Background()121 ctx := context.Background()
120- result, err := uc.Check(ctx, usecase.CheckConfigMigrationInput{})122+ detectResult, err := uc.DetectChanges(ctx, usecase.DetectChangesInput{})
121 if err != nil {123 if err != nil {
122 fmt.Println(renderer.RenderError(err))124 fmt.Println(renderer.RenderError(err))
123 return nil125 return nil
124 }126 }
125 127
126- if !result.NeedsMigration {128+ if !detectResult.HasChanges {
127 fmt.Println(renderer.RenderUpToDate(configFile))129 fmt.Println(renderer.RenderUpToDate(configFile))
128 return nil130 return nil
129 }131 }
130 132
131- // Show config info and missing keys133+ // Show config info with change summary
132- fmt.Println(renderer.RenderConfigInfo(configFile, len(result.MissingKeys)))134+ fmt.Println(renderer.RenderConfigInfo(configFile, len(detectResult.Changes)))
133- fmt.Println(renderer.RenderMissingKeys(result.MissingKeys))135+ fmt.Println(renderer.RenderChangesSummary(detectResult.Changes))
136+ fmt.Println(renderer.RenderChanges(detectResult.Changes))
134 137
135 // If --yes flag, proceed without confirmation138 // If --yes flag, proceed without confirmation
136 if configYes {139 if configYes {
@@ -138,7 +141,7 @@ func runConfigMigrate(_ *cobra.Command, _ []string) error {
138 }141 }
139 142
140 // Show confirmation dialog143 // Show confirmation dialog
141- return runMigrateWithConfirmation(ctx, uc, renderer, app.Theme, result.MissingKeys)144+ return runMigrateWithConfirmation(ctx, uc, renderer, app.Theme, detectResult.Changes)
142 }145 }
143 146
144 // executeMigration performs the actual migration.147 // executeMigration performs the actual migration.
@@ -166,12 +169,12 @@ const (
166 169
167 // migrateModel is the bubbletea model for the migrate confirmation.170 // migrateModel is the bubbletea model for the migrate confirmation.
168 type migrateModel struct {171 type migrateModel struct {
169- spinner spinner.Model172+ spinner spinner.Model
170- renderer *styles.ConfigRenderer173+ renderer *styles.ConfigRenderer
171- confirm styles.ConfirmModel174+ confirm styles.ConfirmModel
172- state migrateState175+ state migrateState
173- uc *usecase.MigrateConfigUseCase176+ uc *usecase.MigrateConfigUseCase
174- missingKeys []port.KeyInfo177+ changes []port.KeyChange
175 178
176 result string179 result string
177 err error180 err error
@@ -188,21 +191,21 @@ func newMigrateModel(
188 renderer *styles.ConfigRenderer,191 renderer *styles.ConfigRenderer,
189 theme *styles.Theme,192 theme *styles.Theme,
190 uc *usecase.MigrateConfigUseCase,193 uc *usecase.MigrateConfigUseCase,
191- missingKeys []port.KeyInfo,194+ changes []port.KeyChange,
192 ) migrateModel {195 ) migrateModel {
193 s := spinner.New()196 s := spinner.New()
194 s.Spinner = spinner.Dot197 s.Spinner = spinner.Dot
195 s.Style = lipgloss.NewStyle().Foreground(theme.Accent)198 s.Style = lipgloss.NewStyle().Foreground(theme.Accent)
196 199
197- confirm := styles.NewConfirm(theme, "Add these settings with default values?")200+ confirm := styles.NewConfirm(theme, "Apply these changes?")
198 201
199 return migrateModel{202 return migrateModel{
200- spinner: s,203+ spinner: s,
201- renderer: renderer,204+ renderer: renderer,
202- confirm: confirm,205+ confirm: confirm,
203- state: migrateStateConfirm,206+ state: migrateStateConfirm,
204- uc: uc,207+ uc: uc,
205- missingKeys: missingKeys,208+ changes: changes,
206 }209 }
207 }210 }
208 211
@@ -289,12 +292,12 @@ func runMigrateWithConfirmation(
289 uc *usecase.MigrateConfigUseCase,292 uc *usecase.MigrateConfigUseCase,
290 renderer *styles.ConfigRenderer,293 renderer *styles.ConfigRenderer,
291 theme *styles.Theme,294 theme *styles.Theme,
292- missingKeys []port.KeyInfo,295+ changes []port.KeyChange,
293 ) error {296 ) error {
294 // Suppress unused ctx warning - kept for consistency with other run functions297 // Suppress unused ctx warning - kept for consistency with other run functions
295 _ = ctx298 _ = ctx
296 299
297- m := newMigrateModel(renderer, theme, uc, missingKeys)300+ m := newMigrateModel(renderer, theme, uc, changes)
298 p := tea.NewProgram(m)301 p := tea.NewProgram(m)
299 302
300 _, err := p.Run()303 _, err := p.Run()
M internal/cli/styles/config.go +113 -5
@@ -21,16 +21,16 @@ func NewConfigRenderer(theme *Theme) *ConfigRenderer {
2121 }
2222
2323 // RenderConfigInfo renders the config file info with status.
24-func (r *ConfigRenderer) RenderConfigInfo(path string, missingCount int) string {
24+func (r *ConfigRenderer) RenderConfigInfo(path string, changeCount int) string {
2525 iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent)
2626 pathStyle := r.theme.Subtle
2727
2828 var status string
29- if missingCount > 0 {
29+ if changeCount > 0 {
3030 countStyle := lipgloss.NewStyle().Foreground(r.theme.Warning)
31- status = fmt.Sprintf("\n %s %s new settings available",
31+ status = fmt.Sprintf("\n %s %s changes detected",
3232 iconStyle.Render(IconInfo),
33- countStyle.Render(fmt.Sprintf("%d", missingCount)),
33+ countStyle.Render(fmt.Sprintf("%d", changeCount)),
3434 )
3535 }
3636
@@ -69,6 +69,114 @@ func (r *ConfigRenderer) RenderMissingKeys(keys []port.KeyInfo) string {
6969 return sb.String()
7070 }
7171
72+// RenderChanges renders the list of detected changes (added, renamed, removed).
73+func (r *ConfigRenderer) RenderChanges(changes []port.KeyChange) string {
74+ if len(changes) == 0 {
75+ return ""
76+ }
77+
78+ addStyle := lipgloss.NewStyle().Foreground(r.theme.Success)
79+ renameStyle := lipgloss.NewStyle().Foreground(r.theme.Warning)
80+ removeStyle := lipgloss.NewStyle().Foreground(r.theme.Error)
81+ keyStyle := r.theme.Highlight
82+ valueStyle := r.theme.Subtle
83+
84+ // Group changes by type
85+ var added, renamed, removed []port.KeyChange
86+ for _, c := range changes {
87+ switch c.Type {
88+ case port.KeyChangeAdded:
89+ added = append(added, c)
90+ case port.KeyChangeRenamed:
91+ renamed = append(renamed, c)
92+ case port.KeyChangeRemoved:
93+ removed = append(removed, c)
94+ }
95+ }
96+
97+ var sb strings.Builder
98+
99+ // Show renamed keys first (most important for user to understand)
100+ if len(renamed) > 0 {
101+ sb.WriteString(fmt.Sprintf("\n Renamed settings (%d):\n", len(renamed)))
102+ for _, c := range renamed {
103+ sb.WriteString(fmt.Sprintf(
104+ " %s %s %s %s\n Value: %s\n",
105+ renameStyle.Render("~"),
106+ valueStyle.Render(c.OldKey),
107+ renameStyle.Render("→"),
108+ keyStyle.Render(c.NewKey),
109+ valueStyle.Render(c.OldValue),
110+ ))
111+ }
112+ }
113+
114+ // Show new settings
115+ if len(added) > 0 {
116+ sb.WriteString(fmt.Sprintf("\n New settings (%d):\n", len(added)))
117+ for _, c := range added {
118+ sb.WriteString(fmt.Sprintf(
119+ " %s %s\n Default: %s\n",
120+ addStyle.Render("+"),
121+ keyStyle.Render(c.NewKey),
122+ valueStyle.Render(c.NewValue),
123+ ))
124+ }
125+ }
126+
127+ // Show deprecated settings (will be removed)
128+ if len(removed) > 0 {
129+ sb.WriteString(fmt.Sprintf("\n Deprecated settings (%d):\n", len(removed)))
130+ for _, c := range removed {
131+ sb.WriteString(fmt.Sprintf(
132+ " %s %s %s\n",
133+ removeStyle.Render("-"),
134+ valueStyle.Render(c.OldKey),
135+ removeStyle.Render("(will be removed)"),
136+ ))
137+ }
138+ }
139+
140+ return sb.String()
141+}
142+
143+// RenderChangesSummary renders a summary of changes for the config info header.
144+func (r *ConfigRenderer) RenderChangesSummary(changes []port.KeyChange) string {
145+ var added, renamed, removed int
146+ for _, c := range changes {
147+ switch c.Type {
148+ case port.KeyChangeAdded:
149+ added++
150+ case port.KeyChangeRenamed:
151+ renamed++
152+ case port.KeyChangeRemoved:
153+ removed++
154+ }
155+ }
156+
157+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent)
158+ addStyle := lipgloss.NewStyle().Foreground(r.theme.Success)
159+ renameStyle := lipgloss.NewStyle().Foreground(r.theme.Warning)
160+ removeStyle := lipgloss.NewStyle().Foreground(r.theme.Error)
161+
162+ var parts []string
163+ if renamed > 0 {
164+ parts = append(parts, renameStyle.Render(fmt.Sprintf("%d renamed", renamed)))
165+ }
166+ if added > 0 {
167+ parts = append(parts, addStyle.Render(fmt.Sprintf("%d new", added)))
168+ }
169+ if removed > 0 {
170+ parts = append(parts, removeStyle.Render(fmt.Sprintf("%d deprecated", removed)))
171+ }
172+
173+ if len(parts) == 0 {
174+ return ""
175+ }
176+
177+ return fmt.Sprintf("\n %s %s", iconStyle.Render(IconInfo), strings.Join(parts, ", "))
178+}
179+
72180 // RenderMigrationSuccess renders the success message after migration.
73181 func (r *ConfigRenderer) RenderMigrationSuccess(count int, path string) string {
74182 iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success)
@@ -79,7 +187,7 @@ func (r *ConfigRenderer) RenderMigrationSuccess(count int, path string) string {
79187 filename := filepath.Base(path)
80188
81189 return fmt.Sprintf(
82- "\n %s Added %s new settings to %s\n",
190+ "\n %s Applied %s changes to %s\n",
83191 iconStyle.Render(IconCheck),
84192 countStyle.Render(fmt.Sprintf("%d", count)),
85193 pathStyle.Render(filename),
@@ -21,16 +21,16 @@ func NewConfigRenderer(theme *Theme) *ConfigRenderer {
21 }21 }
22 22
23 // RenderConfigInfo renders the config file info with status.23 // RenderConfigInfo renders the config file info with status.
24-func (r *ConfigRenderer) RenderConfigInfo(path string, missingCount int) string {24+func (r *ConfigRenderer) RenderConfigInfo(path string, changeCount int) string {
25 iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent)25 iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent)
26 pathStyle := r.theme.Subtle26 pathStyle := r.theme.Subtle
27 27
28 var status string28 var status string
29- if missingCount > 0 {29+ if changeCount > 0 {
30 countStyle := lipgloss.NewStyle().Foreground(r.theme.Warning)30 countStyle := lipgloss.NewStyle().Foreground(r.theme.Warning)
31- status = fmt.Sprintf("\n %s %s new settings available",31+ status = fmt.Sprintf("\n %s %s changes detected",
32 iconStyle.Render(IconInfo),32 iconStyle.Render(IconInfo),
33- countStyle.Render(fmt.Sprintf("%d", missingCount)),33+ countStyle.Render(fmt.Sprintf("%d", changeCount)),
34 )34 )
35 }35 }
36 36
@@ -69,6 +69,114 @@ func (r *ConfigRenderer) RenderMissingKeys(keys []port.KeyInfo) string {
69 return sb.String()69 return sb.String()
70 }70 }
71 71
72+// RenderChanges renders the list of detected changes (added, renamed, removed).
73+func (r *ConfigRenderer) RenderChanges(changes []port.KeyChange) string {
74+ if len(changes) == 0 {
75+ return ""
76+ }
77+
78+ addStyle := lipgloss.NewStyle().Foreground(r.theme.Success)
79+ renameStyle := lipgloss.NewStyle().Foreground(r.theme.Warning)
80+ removeStyle := lipgloss.NewStyle().Foreground(r.theme.Error)
81+ keyStyle := r.theme.Highlight
82+ valueStyle := r.theme.Subtle
83+
84+ // Group changes by type
85+ var added, renamed, removed []port.KeyChange
86+ for _, c := range changes {
87+ switch c.Type {
88+ case port.KeyChangeAdded:
89+ added = append(added, c)
90+ case port.KeyChangeRenamed:
91+ renamed = append(renamed, c)
92+ case port.KeyChangeRemoved:
93+ removed = append(removed, c)
94+ }
95+ }
96+
97+ var sb strings.Builder
98+
99+ // Show renamed keys first (most important for user to understand)
100+ if len(renamed) > 0 {
101+ sb.WriteString(fmt.Sprintf("\n Renamed settings (%d):\n", len(renamed)))
102+ for _, c := range renamed {
103+ sb.WriteString(fmt.Sprintf(
104+ " %s %s %s %s\n Value: %s\n",
105+ renameStyle.Render("~"),
106+ valueStyle.Render(c.OldKey),
107+ renameStyle.Render("→"),
108+ keyStyle.Render(c.NewKey),
109+ valueStyle.Render(c.OldValue),
110+ ))
111+ }
112+ }
113+
114+ // Show new settings
115+ if len(added) > 0 {
116+ sb.WriteString(fmt.Sprintf("\n New settings (%d):\n", len(added)))
117+ for _, c := range added {
118+ sb.WriteString(fmt.Sprintf(
119+ " %s %s\n Default: %s\n",
120+ addStyle.Render("+"),
121+ keyStyle.Render(c.NewKey),
122+ valueStyle.Render(c.NewValue),
123+ ))
124+ }
125+ }
126+
127+ // Show deprecated settings (will be removed)
128+ if len(removed) > 0 {
129+ sb.WriteString(fmt.Sprintf("\n Deprecated settings (%d):\n", len(removed)))
130+ for _, c := range removed {
131+ sb.WriteString(fmt.Sprintf(
132+ " %s %s %s\n",
133+ removeStyle.Render("-"),
134+ valueStyle.Render(c.OldKey),
135+ removeStyle.Render("(will be removed)"),
136+ ))
137+ }
138+ }
139+
140+ return sb.String()
141+}
142+
143+// RenderChangesSummary renders a summary of changes for the config info header.
144+func (r *ConfigRenderer) RenderChangesSummary(changes []port.KeyChange) string {
145+ var added, renamed, removed int
146+ for _, c := range changes {
147+ switch c.Type {
148+ case port.KeyChangeAdded:
149+ added++
150+ case port.KeyChangeRenamed:
151+ renamed++
152+ case port.KeyChangeRemoved:
153+ removed++
154+ }
155+ }
156+
157+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent)
158+ addStyle := lipgloss.NewStyle().Foreground(r.theme.Success)
159+ renameStyle := lipgloss.NewStyle().Foreground(r.theme.Warning)
160+ removeStyle := lipgloss.NewStyle().Foreground(r.theme.Error)
161+
162+ var parts []string
163+ if renamed > 0 {
164+ parts = append(parts, renameStyle.Render(fmt.Sprintf("%d renamed", renamed)))
165+ }
166+ if added > 0 {
167+ parts = append(parts, addStyle.Render(fmt.Sprintf("%d new", added)))
168+ }
169+ if removed > 0 {
170+ parts = append(parts, removeStyle.Render(fmt.Sprintf("%d deprecated", removed)))
171+ }
172+
173+ if len(parts) == 0 {
174+ return ""
175+ }
176+
177+ return fmt.Sprintf("\n %s %s", iconStyle.Render(IconInfo), strings.Join(parts, ", "))
178+}
179+
72 // RenderMigrationSuccess renders the success message after migration.180 // RenderMigrationSuccess renders the success message after migration.
73 func (r *ConfigRenderer) RenderMigrationSuccess(count int, path string) string {181 func (r *ConfigRenderer) RenderMigrationSuccess(count int, path string) string {
74 iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success)182 iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success)
@@ -79,7 +187,7 @@ func (r *ConfigRenderer) RenderMigrationSuccess(count int, path string) string {
79 filename := filepath.Base(path)187 filename := filepath.Base(path)
80 188
81 return fmt.Sprintf(189 return fmt.Sprintf(
82- "\n %s Added %s new settings to %s\n",190+ "\n %s Applied %s changes to %s\n",
83 iconStyle.Render(IconCheck),191 iconStyle.Render(IconCheck),
84 countStyle.Render(fmt.Sprintf("%d", count)),192 countStyle.Render(fmt.Sprintf("%d", count)),
85 pathStyle.Render(filename),193 pathStyle.Render(filename),
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{
A internal/infrastructure/config/diff_formatter.go +40 -0
@@ -0,0 +1,40 @@
1+package config
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+)
9+
10+// ConfigDiffFormatter implements port.DiffFormatter for formatting config changes.
11+type ConfigDiffFormatter struct{}
12+
13+// NewDiffFormatter creates a new ConfigDiffFormatter.
14+func NewDiffFormatter() *ConfigDiffFormatter {
15+ return &ConfigDiffFormatter{}
16+}
17+
18+// FormatChangesAsDiff returns changes formatted as a diff for display.
19+func (*ConfigDiffFormatter) FormatChangesAsDiff(changes []port.KeyChange) string {
20+ if len(changes) == 0 {
21+ return "No changes detected."
22+ }
23+
24+ var sb strings.Builder
25+ sb.WriteString("Config migration changes:\n\n")
26+
27+ for _, change := range changes {
28+ switch change.Type {
29+ case port.KeyChangeAdded:
30+ sb.WriteString(fmt.Sprintf(" + %s = %s\n", change.NewKey, change.NewValue))
31+ case port.KeyChangeRemoved:
32+ sb.WriteString(fmt.Sprintf(" - %s = %s (deprecated)\n", change.OldKey, change.OldValue))
33+ case port.KeyChangeRenamed:
34+ sb.WriteString(fmt.Sprintf(" ~ %s -> %s\n", change.OldKey, change.NewKey))
35+ sb.WriteString(fmt.Sprintf(" (value: %s)\n", change.OldValue))
36+ }
37+ }
38+
39+ return sb.String()
40+}
@@ -0,0 +1,40 @@
1+package config
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+)
9+
10+// ConfigDiffFormatter implements port.DiffFormatter for formatting config changes.
11+type ConfigDiffFormatter struct{}
12+
13+// NewDiffFormatter creates a new ConfigDiffFormatter.
14+func NewDiffFormatter() *ConfigDiffFormatter {
15+ return &ConfigDiffFormatter{}
16+}
17+
18+// FormatChangesAsDiff returns changes formatted as a diff for display.
19+func (*ConfigDiffFormatter) FormatChangesAsDiff(changes []port.KeyChange) string {
20+ if len(changes) == 0 {
21+ return "No changes detected."
22+ }
23+
24+ var sb strings.Builder
25+ sb.WriteString("Config migration changes:\n\n")
26+
27+ for _, change := range changes {
28+ switch change.Type {
29+ case port.KeyChangeAdded:
30+ sb.WriteString(fmt.Sprintf(" + %s = %s\n", change.NewKey, change.NewValue))
31+ case port.KeyChangeRemoved:
32+ sb.WriteString(fmt.Sprintf(" - %s = %s (deprecated)\n", change.OldKey, change.OldValue))
33+ case port.KeyChangeRenamed:
34+ sb.WriteString(fmt.Sprintf(" ~ %s -> %s\n", change.OldKey, change.NewKey))
35+ sb.WriteString(fmt.Sprintf(" (value: %s)\n", change.OldValue))
36+ }
37+ }
38+
39+ return sb.String()
40+}
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 +338 -8
@@ -71,20 +71,293 @@ 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, ensuring each new token is matched at most once
322+ matches := 0
323+ matchedNew := make([]bool, len(newTokens))
324+ for _, ot := range oldTokens {
325+ for j, nt := range newTokens {
326+ if ot == nt && !matchedNew[j] {
327+ matches++
328+ matchedNew[j] = true
329+ break
330+ }
331+ }
332+ }
333+
334+ // If most tokens match, consider similar
335+ minTokens := len(oldTokens)
336+ if len(newTokens) < minTokens {
337+ minTokens = len(newTokens)
338+ }
339+ return matches >= minTokens-1 && matches > 0
340+}
341+
74342 // Migrate adds missing default keys to the user's config file.
75343 func (m *Migrator) Migrate() ([]string, error) {
76- result, err := m.CheckMigration()
344+ changes, err := m.DetectChanges()
77345 if err != nil {
78346 return nil, err
79347 }
80348
81- if result == nil || len(result.MissingKeys) == 0 {
349+ if len(changes) == 0 {
82350 return nil, nil
83351 }
84352
353+ configFile, err := GetConfigFile()
354+ if err != nil {
355+ return nil, fmt.Errorf("failed to get config file path: %w", err)
356+ }
357+
85358 // Create a new Viper instance to read user config
86359 userViper := viper.New()
87- userViper.SetConfigFile(result.ConfigFile)
360+ userViper.SetConfigFile(configFile)
88361 userViper.SetConfigType("toml")
89362
90363 // Set all defaults first
@@ -96,12 +369,31 @@ func (m *Migrator) Migrate() ([]string, error) {
96369 return nil, fmt.Errorf("failed to read config file: %w", err)
97370 }
98371
372+ // Apply changes
373+ var appliedKeys []string
374+ for _, change := range changes {
375+ switch change.Type {
376+ case port.KeyChangeRenamed:
377+ // Copy value from old key to new key
378+ if userViper.IsSet(change.OldKey) {
379+ userViper.Set(change.NewKey, userViper.Get(change.OldKey))
380+ appliedKeys = append(appliedKeys, fmt.Sprintf("%s -> %s", change.OldKey, change.NewKey))
381+ }
382+ case port.KeyChangeAdded:
383+ // New key is already set via defaults, just track it
384+ appliedKeys = append(appliedKeys, change.NewKey)
385+ case port.KeyChangeRemoved:
386+ // Nothing to do - old key will be preserved but not used
387+ appliedKeys = append(appliedKeys, fmt.Sprintf("(deprecated: %s)", change.OldKey))
388+ }
389+ }
390+
99391 // Write the merged config back
100392 if err := userViper.WriteConfig(); err != nil {
101393 return nil, fmt.Errorf("failed to write config file: %w", err)
102394 }
103395
104- return result.MissingKeys, nil
396+ return appliedKeys, nil
105397 }
106398
107399 // GetKeyInfo returns detailed information about a config key.
@@ -142,6 +434,20 @@ func (m *Migrator) getAllDefaultKeys() []string {
142434
143435 // getUserConfigKeys parses the user's TOML file and returns all defined keys.
144436 func (m *Migrator) getUserConfigKeys(configFile string) (map[string]bool, error) {
437+ keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
438+ if err != nil {
439+ return nil, err
440+ }
441+
442+ keys := make(map[string]bool)
443+ for k := range keysWithValues {
444+ keys[k] = true
445+ }
446+ return keys, nil
447+}
448+
449+// getUserConfigKeysWithValues parses the user's TOML file and returns keys with their values.
450+func (m *Migrator) getUserConfigKeysWithValues(configFile string) (map[string]any, error) {
145451 data, err := os.ReadFile(configFile)
146452 if err != nil {
147453 return nil, fmt.Errorf("failed to read config file: %w", err)
@@ -153,11 +459,35 @@ func (m *Migrator) getUserConfigKeys(configFile string) (map[string]bool, error)
153459 return nil, fmt.Errorf("failed to parse TOML: %w", err)
154460 }
155461
156- // Flatten the map to dot-notation keys
157- keys := make(map[string]bool)
158- m.flattenMap(rawConfig, "", keys)
462+ // Flatten the map to dot-notation keys with values
463+ result := make(map[string]any)
464+ m.flattenMapWithValues(rawConfig, "", result)
159465
160- return keys, nil
466+ return result, nil
467+}
468+
469+// flattenMapWithValues recursively flattens a nested map to dot-notation keys with values.
470+func (m *Migrator) flattenMapWithValues(data map[string]any, prefix string, result map[string]any) {
471+ for k, v := range data {
472+ key := k
473+ if prefix != "" {
474+ key = prefix + "." + k
475+ }
476+
477+ switch val := v.(type) {
478+ case map[string]any:
479+ // Check if this key represents user data (like search_shortcuts, actions)
480+ // that should be treated as a single key rather than recursed into.
481+ if m.isUserDataSection(key) {
482+ result[key] = v
483+ } else {
484+ // Recurse into nested config sections
485+ m.flattenMapWithValues(val, key, result)
486+ }
487+ default:
488+ result[key] = v
489+ }
490+ }
161491 }
162492
163493 // flattenMap recursively flattens a nested map to dot-notation keys.
@@ -71,20 +71,293 @@ 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, ensuring each new token is matched at most once
322+ matches := 0
323+ matchedNew := make([]bool, len(newTokens))
324+ for _, ot := range oldTokens {
325+ for j, nt := range newTokens {
326+ if ot == nt && !matchedNew[j] {
327+ matches++
328+ matchedNew[j] = true
329+ break
330+ }
331+ }
332+ }
333+
334+ // If most tokens match, consider similar
335+ minTokens := len(oldTokens)
336+ if len(newTokens) < minTokens {
337+ minTokens = len(newTokens)
338+ }
339+ return matches >= minTokens-1 && matches > 0
340+}
341+
74 // Migrate adds missing default keys to the user's config file.342 // Migrate adds missing default keys to the user's config file.
75 func (m *Migrator) Migrate() ([]string, error) {343 func (m *Migrator) Migrate() ([]string, error) {
76- result, err := m.CheckMigration()344+ changes, err := m.DetectChanges()
77 if err != nil {345 if err != nil {
78 return nil, err346 return nil, err
79 }347 }
80 348
81- if result == nil || len(result.MissingKeys) == 0 {349+ if len(changes) == 0 {
82 return nil, nil350 return nil, nil
83 }351 }
84 352
353+ configFile, err := GetConfigFile()
354+ if err != nil {
355+ return nil, fmt.Errorf("failed to get config file path: %w", err)
356+ }
357+
85 // Create a new Viper instance to read user config358 // Create a new Viper instance to read user config
86 userViper := viper.New()359 userViper := viper.New()
87- userViper.SetConfigFile(result.ConfigFile)360+ userViper.SetConfigFile(configFile)
88 userViper.SetConfigType("toml")361 userViper.SetConfigType("toml")
89 362
90 // Set all defaults first363 // Set all defaults first
@@ -96,12 +369,31 @@ func (m *Migrator) Migrate() ([]string, error) {
96 return nil, fmt.Errorf("failed to read config file: %w", err)369 return nil, fmt.Errorf("failed to read config file: %w", err)
97 }370 }
98 371
372+ // Apply changes
373+ var appliedKeys []string
374+ for _, change := range changes {
375+ switch change.Type {
376+ case port.KeyChangeRenamed:
377+ // Copy value from old key to new key
378+ if userViper.IsSet(change.OldKey) {
379+ userViper.Set(change.NewKey, userViper.Get(change.OldKey))
380+ appliedKeys = append(appliedKeys, fmt.Sprintf("%s -> %s", change.OldKey, change.NewKey))
381+ }
382+ case port.KeyChangeAdded:
383+ // New key is already set via defaults, just track it
384+ appliedKeys = append(appliedKeys, change.NewKey)
385+ case port.KeyChangeRemoved:
386+ // Nothing to do - old key will be preserved but not used
387+ appliedKeys = append(appliedKeys, fmt.Sprintf("(deprecated: %s)", change.OldKey))
388+ }
389+ }
390+
99 // Write the merged config back391 // Write the merged config back
100 if err := userViper.WriteConfig(); err != nil {392 if err := userViper.WriteConfig(); err != nil {
101 return nil, fmt.Errorf("failed to write config file: %w", err)393 return nil, fmt.Errorf("failed to write config file: %w", err)
102 }394 }
103 395
104- return result.MissingKeys, nil396+ return appliedKeys, nil
105 }397 }
106 398
107 // GetKeyInfo returns detailed information about a config key.399 // GetKeyInfo returns detailed information about a config key.
@@ -142,6 +434,20 @@ func (m *Migrator) getAllDefaultKeys() []string {
142 434
143 // getUserConfigKeys parses the user's TOML file and returns all defined keys.435 // getUserConfigKeys parses the user's TOML file and returns all defined keys.
144 func (m *Migrator) getUserConfigKeys(configFile string) (map[string]bool, error) {436 func (m *Migrator) getUserConfigKeys(configFile string) (map[string]bool, error) {
437+ keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
438+ if err != nil {
439+ return nil, err
440+ }
441+
442+ keys := make(map[string]bool)
443+ for k := range keysWithValues {
444+ keys[k] = true
445+ }
446+ return keys, nil
447+}
448+
449+// getUserConfigKeysWithValues parses the user's TOML file and returns keys with their values.
450+func (m *Migrator) getUserConfigKeysWithValues(configFile string) (map[string]any, error) {
145 data, err := os.ReadFile(configFile)451 data, err := os.ReadFile(configFile)
146 if err != nil {452 if err != nil {
147 return nil, fmt.Errorf("failed to read config file: %w", err)453 return nil, fmt.Errorf("failed to read config file: %w", err)
@@ -153,11 +459,35 @@ func (m *Migrator) getUserConfigKeys(configFile string) (map[string]bool, error)
153 return nil, fmt.Errorf("failed to parse TOML: %w", err)459 return nil, fmt.Errorf("failed to parse TOML: %w", err)
154 }460 }
155 461
156- // Flatten the map to dot-notation keys462+ // Flatten the map to dot-notation keys with values
157- keys := make(map[string]bool)463+ result := make(map[string]any)
158- m.flattenMap(rawConfig, "", keys)464+ m.flattenMapWithValues(rawConfig, "", result)
159 465
160- return keys, nil466+ return result, nil
467+}
468+
469+// flattenMapWithValues recursively flattens a nested map to dot-notation keys with values.
470+func (m *Migrator) flattenMapWithValues(data map[string]any, prefix string, result map[string]any) {
471+ for k, v := range data {
472+ key := k
473+ if prefix != "" {
474+ key = prefix + "." + k
475+ }
476+
477+ switch val := v.(type) {
478+ case map[string]any:
479+ // Check if this key represents user data (like search_shortcuts, actions)
480+ // that should be treated as a single key rather than recursed into.
481+ if m.isUserDataSection(key) {
482+ result[key] = v
483+ } else {
484+ // Recurse into nested config sections
485+ m.flattenMapWithValues(val, key, result)
486+ }
487+ default:
488+ result[key] = v
489+ }
490+ }
161 }491 }
162 492
163 // flattenMap recursively flattens a nested map to dot-notation keys.493 // flattenMap recursively flattens a nested map to dot-notation keys.
M internal/infrastructure/config/migrate_test.go +277 -0
@@ -340,6 +340,283 @@ func TestMigrator_FormatValue(t *testing.T) {
340340 }
341341 }
342342
343+func TestKeysAreSimilar(t *testing.T) {
344+ tests := []struct {
345+ name string
346+ oldKey string
347+ newKey string
348+ expected bool
349+ }{
350+ {
351+ name: "identical keys",
352+ oldKey: "section.pane_mode_color",
353+ newKey: "section.pane_mode_color",
354+ expected: true,
355+ },
356+ {
357+ name: "substring match - old contains new",
358+ oldKey: "section.pane_mode_border_color",
359+ newKey: "section.pane_mode_color",
360+ expected: true,
361+ },
362+ {
363+ name: "substring match - new contains old",
364+ oldKey: "section.mode_color",
365+ newKey: "section.pane_mode_color",
366+ expected: true,
367+ },
368+ {
369+ name: "token matching - border removed",
370+ oldKey: "section.tab_mode_border_color",
371+ newKey: "section.tab_mode_color",
372+ expected: true,
373+ },
374+ {
375+ name: "different parent paths",
376+ oldKey: "section_a.pane_mode_color",
377+ newKey: "section_b.pane_mode_color",
378+ expected: false,
379+ },
380+ {
381+ name: "different depth",
382+ oldKey: "pane_mode_color",
383+ newKey: "section.pane_mode_color",
384+ expected: false,
385+ },
386+ {
387+ name: "completely different keys",
388+ oldKey: "section.foo_bar",
389+ newKey: "section.baz_qux",
390+ expected: false,
391+ },
392+ {
393+ name: "top level keys rejected",
394+ oldKey: "foo",
395+ newKey: "bar",
396+ expected: false,
397+ },
398+ {
399+ name: "no common tokens",
400+ oldKey: "section.aaa_bbb",
401+ newKey: "section.ccc_ddd",
402+ expected: false,
403+ },
404+ {
405+ name: "single token difference allowed",
406+ oldKey: "section.pane_mode_border_width",
407+ newKey: "section.mode_border_width",
408+ expected: true,
409+ },
410+ {
411+ name: "duplicate tokens in old key - no double counting",
412+ oldKey: "section.mode_mode_mode",
413+ newKey: "section.mode_other_another",
414+ expected: false, // oldTokens=[mode,mode,mode], newTokens=[mode,other,another], matches=1 (only one mode can match), minTokens=3, needs 1>=2 which is false
415+ },
416+ {
417+ name: "enough common tokens with duplicates",
418+ oldKey: "section.pane_mode_border",
419+ newKey: "section.pane_mode_color",
420+ expected: true, // oldTokens=[pane,mode,border], newTokens=[pane,mode,color], matches=2, minTokens=3, 2>=2 && 2>0 = true
421+ },
422+ }
423+
424+ for _, tt := range tests {
425+ t.Run(tt.name, func(t *testing.T) {
426+ result := keysAreSimilar(tt.oldKey, tt.newKey)
427+ assert.Equal(t, tt.expected, result, "keysAreSimilar(%q, %q)", tt.oldKey, tt.newKey)
428+ })
429+ }
430+}
431+
432+func TestMigrator_TypesAreCompatible(t *testing.T) {
433+ m := NewMigrator()
434+
435+ tests := []struct {
436+ name string
437+ oldKey string
438+ newKey string
439+ expected bool
440+ }{
441+ {
442+ name: "color to color - compatible (using real key)",
443+ oldKey: "workspace.styling.pane_mode_border_color",
444+ newKey: "workspace.styling.pane_mode_color",
445+ expected: true,
446+ },
447+ {
448+ name: "width to width - compatible (using real key)",
449+ oldKey: "workspace.styling.pane_mode_border_width",
450+ newKey: "workspace.styling.mode_border_width",
451+ expected: true,
452+ },
453+ {
454+ name: "width suffix to color type - incompatible",
455+ oldKey: "workspace.styling.some_width",
456+ newKey: "workspace.styling.pane_mode_color", // real key with string type
457+ expected: false,
458+ },
459+ {
460+ name: "color suffix to int type - incompatible",
461+ oldKey: "workspace.styling.some_color",
462+ newKey: "workspace.styling.mode_border_width", // real key with int type
463+ expected: false,
464+ },
465+ {
466+ name: "generic key to string type - compatible",
467+ oldKey: "workspace.styling.some_setting",
468+ newKey: "workspace.styling.pane_mode_color", // real key with string type
469+ expected: true,
470+ },
471+ {
472+ name: "non-existent new key - incompatible",
473+ oldKey: "section.old_key",
474+ newKey: "section.nonexistent_key_xyz",
475+ expected: false,
476+ },
477+ }
478+
479+ for _, tt := range tests {
480+ t.Run(tt.name, func(t *testing.T) {
481+ result := m.typesAreCompatible(tt.oldKey, tt.newKey)
482+ assert.Equal(t, tt.expected, result, "typesAreCompatible(%q, %q)", tt.oldKey, tt.newKey)
483+ })
484+ }
485+}
486+
487+func TestMigrator_FindDeprecatedKeys(t *testing.T) {
488+ m := NewMigrator()
489+
490+ tests := []struct {
491+ name string
492+ userKeys map[string]any
493+ defaultKeys map[string]bool
494+ expected []string
495+ }{
496+ {
497+ name: "no deprecated keys",
498+ userKeys: map[string]any{
499+ "history.max_entries": 1000,
500+ "logging.level": "info",
501+ },
502+ defaultKeys: map[string]bool{
503+ "history.max_entries": true,
504+ "logging.level": true,
505+ },
506+ expected: []string{},
507+ },
508+ {
509+ name: "one deprecated key",
510+ userKeys: map[string]any{
511+ "history.max_entries": 1000,
512+ "old.deprecated_key": "value",
513+ },
514+ defaultKeys: map[string]bool{
515+ "history.max_entries": true,
516+ },
517+ expected: []string{"old.deprecated_key"},
518+ },
519+ {
520+ name: "multiple deprecated keys",
521+ userKeys: map[string]any{
522+ "old.key1": "a",
523+ "old.key2": "b",
524+ "old.key3": "c",
525+ },
526+ defaultKeys: map[string]bool{
527+ "new.key1": true,
528+ },
529+ expected: []string{"old.key1", "old.key2", "old.key3"},
530+ },
531+ {
532+ name: "parent key exists in defaults",
533+ userKeys: map[string]any{
534+ "parent.child": "value",
535+ },
536+ defaultKeys: map[string]bool{
537+ "parent.child": true,
538+ "parent.other_child": true,
539+ },
540+ expected: []string{},
541+ },
542+ }
543+
544+ for _, tt := range tests {
545+ t.Run(tt.name, func(t *testing.T) {
546+ result := m.findDeprecatedKeys(tt.userKeys, tt.defaultKeys)
547+ assert.ElementsMatch(t, tt.expected, result)
548+ })
549+ }
550+}
551+
552+func TestMigrator_MatchRenamedKeys(t *testing.T) {
553+ m := NewMigrator()
554+
555+ tests := []struct {
556+ name string
557+ deprecated []string
558+ missing []string
559+ expectedRenames map[string]string
560+ expectedUnmatchedOld []string
561+ expectedUnmatchedNew []string
562+ }{
563+ {
564+ name: "no keys to match",
565+ deprecated: []string{},
566+ missing: []string{},
567+ expectedRenames: map[string]string{},
568+ expectedUnmatchedOld: nil,
569+ expectedUnmatchedNew: nil,
570+ },
571+ {
572+ name: "simple rename match (using real keys)",
573+ deprecated: []string{"workspace.styling.pane_mode_border_color"},
574+ missing: []string{"workspace.styling.pane_mode_color"},
575+ expectedRenames: map[string]string{
576+ "workspace.styling.pane_mode_border_color": "workspace.styling.pane_mode_color",
577+ },
578+ expectedUnmatchedOld: nil,
579+ expectedUnmatchedNew: nil,
580+ },
581+ {
582+ name: "no match possible - different types (width vs color)",
583+ deprecated: []string{"workspace.styling.pane_mode_border_width"},
584+ missing: []string{"workspace.styling.pane_mode_color"},
585+ expectedRenames: map[string]string{},
586+ expectedUnmatchedOld: []string{"workspace.styling.pane_mode_border_width"},
587+ expectedUnmatchedNew: []string{"workspace.styling.pane_mode_color"},
588+ },
589+ {
590+ name: "no match possible - different parents",
591+ deprecated: []string{"old_section.some_key"},
592+ missing: []string{"new_section.some_key"},
593+ expectedRenames: map[string]string{},
594+ expectedUnmatchedOld: []string{"old_section.some_key"},
595+ expectedUnmatchedNew: []string{"new_section.some_key"},
596+ },
597+ {
598+ name: "multiple renames (using real keys)",
599+ deprecated: []string{"workspace.styling.pane_mode_border_color", "workspace.styling.tab_mode_border_color"},
600+ missing: []string{"workspace.styling.pane_mode_color", "workspace.styling.tab_mode_color"},
601+ expectedRenames: map[string]string{
602+ "workspace.styling.pane_mode_border_color": "workspace.styling.pane_mode_color",
603+ "workspace.styling.tab_mode_border_color": "workspace.styling.tab_mode_color",
604+ },
605+ expectedUnmatchedOld: nil,
606+ expectedUnmatchedNew: nil,
607+ },
608+ }
609+
610+ for _, tt := range tests {
611+ t.Run(tt.name, func(t *testing.T) {
612+ renames, unmatchedOld, unmatchedNew := m.matchRenamedKeys(tt.deprecated, tt.missing)
613+ assert.Equal(t, tt.expectedRenames, renames, "renames mismatch")
614+ assert.ElementsMatch(t, tt.expectedUnmatchedOld, unmatchedOld, "unmatched old mismatch")
615+ assert.ElementsMatch(t, tt.expectedUnmatchedNew, unmatchedNew, "unmatched new mismatch")
616+ })
617+ }
618+}
619+
343620 func TestMigrator_CheckMigration_NoConfigFile(t *testing.T) {
344621 // This test would need to temporarily modify the config path
345622 // which is difficult without refactoring. Skip for now.
@@ -340,6 +340,283 @@ func TestMigrator_FormatValue(t *testing.T) {
340 }340 }
341 }341 }
342 342
343+func TestKeysAreSimilar(t *testing.T) {
344+ tests := []struct {
345+ name string
346+ oldKey string
347+ newKey string
348+ expected bool
349+ }{
350+ {
351+ name: "identical keys",
352+ oldKey: "section.pane_mode_color",
353+ newKey: "section.pane_mode_color",
354+ expected: true,
355+ },
356+ {
357+ name: "substring match - old contains new",
358+ oldKey: "section.pane_mode_border_color",
359+ newKey: "section.pane_mode_color",
360+ expected: true,
361+ },
362+ {
363+ name: "substring match - new contains old",
364+ oldKey: "section.mode_color",
365+ newKey: "section.pane_mode_color",
366+ expected: true,
367+ },
368+ {
369+ name: "token matching - border removed",
370+ oldKey: "section.tab_mode_border_color",
371+ newKey: "section.tab_mode_color",
372+ expected: true,
373+ },
374+ {
375+ name: "different parent paths",
376+ oldKey: "section_a.pane_mode_color",
377+ newKey: "section_b.pane_mode_color",
378+ expected: false,
379+ },
380+ {
381+ name: "different depth",
382+ oldKey: "pane_mode_color",
383+ newKey: "section.pane_mode_color",
384+ expected: false,
385+ },
386+ {
387+ name: "completely different keys",
388+ oldKey: "section.foo_bar",
389+ newKey: "section.baz_qux",
390+ expected: false,
391+ },
392+ {
393+ name: "top level keys rejected",
394+ oldKey: "foo",
395+ newKey: "bar",
396+ expected: false,
397+ },
398+ {
399+ name: "no common tokens",
400+ oldKey: "section.aaa_bbb",
401+ newKey: "section.ccc_ddd",
402+ expected: false,
403+ },
404+ {
405+ name: "single token difference allowed",
406+ oldKey: "section.pane_mode_border_width",
407+ newKey: "section.mode_border_width",
408+ expected: true,
409+ },
410+ {
411+ name: "duplicate tokens in old key - no double counting",
412+ oldKey: "section.mode_mode_mode",
413+ newKey: "section.mode_other_another",
414+ expected: false, // oldTokens=[mode,mode,mode], newTokens=[mode,other,another], matches=1 (only one mode can match), minTokens=3, needs 1>=2 which is false
415+ },
416+ {
417+ name: "enough common tokens with duplicates",
418+ oldKey: "section.pane_mode_border",
419+ newKey: "section.pane_mode_color",
420+ expected: true, // oldTokens=[pane,mode,border], newTokens=[pane,mode,color], matches=2, minTokens=3, 2>=2 && 2>0 = true
421+ },
422+ }
423+
424+ for _, tt := range tests {
425+ t.Run(tt.name, func(t *testing.T) {
426+ result := keysAreSimilar(tt.oldKey, tt.newKey)
427+ assert.Equal(t, tt.expected, result, "keysAreSimilar(%q, %q)", tt.oldKey, tt.newKey)
428+ })
429+ }
430+}
431+
432+func TestMigrator_TypesAreCompatible(t *testing.T) {
433+ m := NewMigrator()
434+
435+ tests := []struct {
436+ name string
437+ oldKey string
438+ newKey string
439+ expected bool
440+ }{
441+ {
442+ name: "color to color - compatible (using real key)",
443+ oldKey: "workspace.styling.pane_mode_border_color",
444+ newKey: "workspace.styling.pane_mode_color",
445+ expected: true,
446+ },
447+ {
448+ name: "width to width - compatible (using real key)",
449+ oldKey: "workspace.styling.pane_mode_border_width",
450+ newKey: "workspace.styling.mode_border_width",
451+ expected: true,
452+ },
453+ {
454+ name: "width suffix to color type - incompatible",
455+ oldKey: "workspace.styling.some_width",
456+ newKey: "workspace.styling.pane_mode_color", // real key with string type
457+ expected: false,
458+ },
459+ {
460+ name: "color suffix to int type - incompatible",
461+ oldKey: "workspace.styling.some_color",
462+ newKey: "workspace.styling.mode_border_width", // real key with int type
463+ expected: false,
464+ },
465+ {
466+ name: "generic key to string type - compatible",
467+ oldKey: "workspace.styling.some_setting",
468+ newKey: "workspace.styling.pane_mode_color", // real key with string type
469+ expected: true,
470+ },
471+ {
472+ name: "non-existent new key - incompatible",
473+ oldKey: "section.old_key",
474+ newKey: "section.nonexistent_key_xyz",
475+ expected: false,
476+ },
477+ }
478+
479+ for _, tt := range tests {
480+ t.Run(tt.name, func(t *testing.T) {
481+ result := m.typesAreCompatible(tt.oldKey, tt.newKey)
482+ assert.Equal(t, tt.expected, result, "typesAreCompatible(%q, %q)", tt.oldKey, tt.newKey)
483+ })
484+ }
485+}
486+
487+func TestMigrator_FindDeprecatedKeys(t *testing.T) {
488+ m := NewMigrator()
489+
490+ tests := []struct {
491+ name string
492+ userKeys map[string]any
493+ defaultKeys map[string]bool
494+ expected []string
495+ }{
496+ {
497+ name: "no deprecated keys",
498+ userKeys: map[string]any{
499+ "history.max_entries": 1000,
500+ "logging.level": "info",
501+ },
502+ defaultKeys: map[string]bool{
503+ "history.max_entries": true,
504+ "logging.level": true,
505+ },
506+ expected: []string{},
507+ },
508+ {
509+ name: "one deprecated key",
510+ userKeys: map[string]any{
511+ "history.max_entries": 1000,
512+ "old.deprecated_key": "value",
513+ },
514+ defaultKeys: map[string]bool{
515+ "history.max_entries": true,
516+ },
517+ expected: []string{"old.deprecated_key"},
518+ },
519+ {
520+ name: "multiple deprecated keys",
521+ userKeys: map[string]any{
522+ "old.key1": "a",
523+ "old.key2": "b",
524+ "old.key3": "c",
525+ },
526+ defaultKeys: map[string]bool{
527+ "new.key1": true,
528+ },
529+ expected: []string{"old.key1", "old.key2", "old.key3"},
530+ },
531+ {
532+ name: "parent key exists in defaults",
533+ userKeys: map[string]any{
534+ "parent.child": "value",
535+ },
536+ defaultKeys: map[string]bool{
537+ "parent.child": true,
538+ "parent.other_child": true,
539+ },
540+ expected: []string{},
541+ },
542+ }
543+
544+ for _, tt := range tests {
545+ t.Run(tt.name, func(t *testing.T) {
546+ result := m.findDeprecatedKeys(tt.userKeys, tt.defaultKeys)
547+ assert.ElementsMatch(t, tt.expected, result)
548+ })
549+ }
550+}
551+
552+func TestMigrator_MatchRenamedKeys(t *testing.T) {
553+ m := NewMigrator()
554+
555+ tests := []struct {
556+ name string
557+ deprecated []string
558+ missing []string
559+ expectedRenames map[string]string
560+ expectedUnmatchedOld []string
561+ expectedUnmatchedNew []string
562+ }{
563+ {
564+ name: "no keys to match",
565+ deprecated: []string{},
566+ missing: []string{},
567+ expectedRenames: map[string]string{},
568+ expectedUnmatchedOld: nil,
569+ expectedUnmatchedNew: nil,
570+ },
571+ {
572+ name: "simple rename match (using real keys)",
573+ deprecated: []string{"workspace.styling.pane_mode_border_color"},
574+ missing: []string{"workspace.styling.pane_mode_color"},
575+ expectedRenames: map[string]string{
576+ "workspace.styling.pane_mode_border_color": "workspace.styling.pane_mode_color",
577+ },
578+ expectedUnmatchedOld: nil,
579+ expectedUnmatchedNew: nil,
580+ },
581+ {
582+ name: "no match possible - different types (width vs color)",
583+ deprecated: []string{"workspace.styling.pane_mode_border_width"},
584+ missing: []string{"workspace.styling.pane_mode_color"},
585+ expectedRenames: map[string]string{},
586+ expectedUnmatchedOld: []string{"workspace.styling.pane_mode_border_width"},
587+ expectedUnmatchedNew: []string{"workspace.styling.pane_mode_color"},
588+ },
589+ {
590+ name: "no match possible - different parents",
591+ deprecated: []string{"old_section.some_key"},
592+ missing: []string{"new_section.some_key"},
593+ expectedRenames: map[string]string{},
594+ expectedUnmatchedOld: []string{"old_section.some_key"},
595+ expectedUnmatchedNew: []string{"new_section.some_key"},
596+ },
597+ {
598+ name: "multiple renames (using real keys)",
599+ deprecated: []string{"workspace.styling.pane_mode_border_color", "workspace.styling.tab_mode_border_color"},
600+ missing: []string{"workspace.styling.pane_mode_color", "workspace.styling.tab_mode_color"},
601+ expectedRenames: map[string]string{
602+ "workspace.styling.pane_mode_border_color": "workspace.styling.pane_mode_color",
603+ "workspace.styling.tab_mode_border_color": "workspace.styling.tab_mode_color",
604+ },
605+ expectedUnmatchedOld: nil,
606+ expectedUnmatchedNew: nil,
607+ },
608+ }
609+
610+ for _, tt := range tests {
611+ t.Run(tt.name, func(t *testing.T) {
612+ renames, unmatchedOld, unmatchedNew := m.matchRenamedKeys(tt.deprecated, tt.missing)
613+ assert.Equal(t, tt.expectedRenames, renames, "renames mismatch")
614+ assert.ElementsMatch(t, tt.expectedUnmatchedOld, unmatchedOld, "unmatched old mismatch")
615+ assert.ElementsMatch(t, tt.expectedUnmatchedNew, unmatchedNew, "unmatched new mismatch")
616+ })
617+ }
618+}
619+
343 func TestMigrator_CheckMigration_NoConfigFile(t *testing.T) {620 func TestMigrator_CheckMigration_NoConfigFile(t *testing.T) {
344 // This test would need to temporarily modify the config path621 // This test would need to temporarily modify the config path
345 // which is difficult without refactoring. Skip for now.622 // which is difficult without refactoring. Skip for now.
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 +60 -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,52 @@ 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+ // Mode class is applied atomically with Show() to avoid visual flicker.
1311+ modeClass := getModeToastClass(mode)
1312+ a.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo,
1313+ component.WithDuration(0), // Persistent until mode exits.
1314+ component.WithPosition(component.ToastPositionBottomLeft),
1315+ component.WithModeClass(modeClass),
1316+ )
1317+}
1318+
1319+// getModeToastClass returns the CSS class for the given mode's toast styling.
1320+func getModeToastClass(mode input.Mode) string {
1321+ switch mode {
1322+ case input.ModePane:
1323+ return "toast-pane-mode"
1324+ case input.ModeTab:
1325+ return "toast-tab-mode"
1326+ case input.ModeSession:
1327+ return "toast-session-mode"
1328+ case input.ModeResize:
1329+ return "toast-resize-mode"
1330+ default:
1331+ return ""
1332+ }
12731333 }
12741334
12751335 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,52 @@ 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+ // Mode class is applied atomically with Show() to avoid visual flicker.
1311+ modeClass := getModeToastClass(mode)
1312+ a.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo,
1313+ component.WithDuration(0), // Persistent until mode exits.
1314+ component.WithPosition(component.ToastPositionBottomLeft),
1315+ component.WithModeClass(modeClass),
1316+ )
1317+}
1318+
1319+// getModeToastClass returns the CSS class for the given mode's toast styling.
1320+func getModeToastClass(mode input.Mode) string {
1321+ switch mode {
1322+ case input.ModePane:
1323+ return "toast-pane-mode"
1324+ case input.ModeTab:
1325+ return "toast-tab-mode"
1326+ case input.ModeSession:
1327+ return "toast-session-mode"
1328+ case input.ModeResize:
1329+ return "toast-resize-mode"
1330+ default:
1331+ return ""
1332+ }
1273 }1333 }
1274 1334
1275 func (a *App) clearResizeModeBorder() {1335 func (a *App) clearResizeModeBorder() {
M internal/ui/component/toaster.go +239 -8
@@ -48,6 +48,89 @@ 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+ // ModeClass is a CSS class for mode-specific styling (e.g., "toast-pane-mode").
82+ // When set, this class is applied atomically with Show() to avoid visual flicker.
83+ ModeClass string
84+}
85+
86+// ToastOption is a functional option for configuring toast display.
87+type ToastOption func(*ToastOptions)
88+
89+// WithDuration sets the auto-dismiss duration in milliseconds.
90+// Use 0 for persistent toasts that require manual dismissal.
91+func WithDuration(ms int) ToastOption {
92+ return func(o *ToastOptions) {
93+ o.Duration = ms
94+ }
95+}
96+
97+// WithBackgroundColor sets a custom background color (CSS color value).
98+func WithBackgroundColor(color string) ToastOption {
99+ return func(o *ToastOptions) {
100+ o.BackgroundColor = color
101+ }
102+}
103+
104+// WithTextColor sets a custom text color (CSS color value).
105+func WithTextColor(color string) ToastOption {
106+ return func(o *ToastOptions) {
107+ o.TextColor = color
108+ }
109+}
110+
111+// WithPosition sets the toast position on screen.
112+func WithPosition(pos ToastPosition) ToastOption {
113+ return func(o *ToastOptions) {
114+ o.Position = pos
115+ }
116+}
117+
118+// WithModeClass sets a mode-specific CSS class for styling.
119+// This is applied atomically within Show() to avoid visual flicker.
120+func WithModeClass(class string) ToastOption {
121+ return func(o *ToastOptions) {
122+ o.ModeClass = class
123+ }
124+}
125+
126+// defaultToastOptions returns the default toast options.
127+func defaultToastOptions() ToastOptions {
128+ return ToastOptions{
129+ Duration: toastDismissTimeoutMs,
130+ Position: ToastPositionTopLeft,
131+ }
132+}
133+
51134 // Toaster displays toast notifications in an overlay.
52135 // It supports different notification levels and auto-dismissal.
53136 // When a new toast is shown while one is already visible, the text
@@ -60,18 +143,26 @@ type Toaster struct {
60143 visible bool
61144 dismissTimer uint // GLib timer source ID
62145
146+ // Track current options for cleanup
147+ currentOpts ToastOptions
148+ hasCustomStyle bool
149+ customBgColor string // Current custom background color
150+ customTextColor string // Current custom text color
151+ customModeClass string // Current mode-specific CSS class (e.g., "toast-pane-mode")
152+ customCSSProvider *gtk.CssProvider // CSS provider for inline custom colors
153+
63154 mu sync.Mutex
64155 }
65156
66157 // NewToaster creates a new toaster component for overlay display.
67-// The toaster is positioned in the top-left corner with margin.
158+// The toaster is positioned in the top-left corner with margin by default.
68159 func NewToaster(factory layout.WidgetFactory) *Toaster {
69160 // Create container box for styling and positioning
70161 container := factory.NewBox(layout.OrientationHorizontal, 0)
71162 container.AddCssClass("toast")
72163 container.AddCssClass("toast-info") // Default level
73164
74- // Position in top-left with margin
165+ // Position in top-left with margin (default)
75166 container.SetHalign(gtk.AlignStartValue)
76167 container.SetValign(gtk.AlignStartValue)
77168
@@ -98,17 +189,28 @@ func NewToaster(factory layout.WidgetFactory) *Toaster {
98189 label: label,
99190 currentLevel: ToastInfo,
100191 visible: false,
192+ currentOpts: defaultToastOptions(),
101193 }
102194 }
103195
104196 // Show displays a toast notification with the given message and level.
105197 // 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) {
198+// Optional ToastOption arguments can customize duration, colors, and position.
199+func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel, opts ...ToastOption) {
107200 log := logging.FromContext(ctx)
108201
202+ // Build options from defaults and overrides
203+ options := defaultToastOptions()
204+ for _, opt := range opts {
205+ opt(&options)
206+ }
207+
109208 t.mu.Lock()
110209 defer t.mu.Unlock()
111210
211+ // Clear previous custom styles if any
212+ t.clearCustomStyle()
213+
112214 // Update level CSS class if changed
113215 if t.currentLevel != level {
114216 t.container.RemoveCssClass(t.currentLevel.cssClass())
@@ -116,6 +218,17 @@ func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel) {
116218 t.currentLevel = level
117219 }
118220
221+ // Apply custom colors if specified
222+ t.applyCustomStyle(options)
223+
224+ // Apply mode class if specified (atomically to avoid flicker)
225+ if options.ModeClass != "" {
226+ t.applyModeClassInternal(options.ModeClass)
227+ }
228+
229+ // Apply position
230+ t.applyPosition(options.Position)
231+
119232 // Update message text
120233 t.label.SetText(message)
121234
@@ -131,12 +244,18 @@ func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel) {
131244 t.container.SetVisible(true)
132245 }
133246
134- // Start new dismiss timer
135- t.startDismissTimer(ctx)
247+ // Start new dismiss timer (if duration > 0)
248+ if options.Duration > 0 {
249+ t.startDismissTimerWithDuration(ctx, options.Duration)
250+ }
251+ // Duration == 0 means persistent, no timer
252+
253+ t.currentOpts = options
136254
137255 log.Debug().
138256 Str("toast_message", message).
139257 Int("toast_level", int(level)).
258+ Int("duration", options.Duration).
140259 Msg("toast shown")
141260 }
142261
@@ -167,13 +286,125 @@ func (t *Toaster) hide() {
167286 t.dismissTimer = 0
168287 }
169288
289+ // Clear custom styles
290+ t.clearCustomStyle()
291+
170292 t.visible = false
171293 t.container.SetVisible(false)
172294 }
173295
174-// startDismissTimer starts the auto-dismiss timer.
296+// applyPosition sets the widget alignment based on the position.
297+func (t *Toaster) applyPosition(pos ToastPosition) {
298+ var halign, valign gtk.Align
299+
300+ switch pos {
301+ case ToastPositionTopLeft:
302+ halign = gtk.AlignStartValue
303+ valign = gtk.AlignStartValue
304+ case ToastPositionTopCenter:
305+ halign = gtk.AlignCenterValue
306+ valign = gtk.AlignStartValue
307+ case ToastPositionTopRight:
308+ halign = gtk.AlignEndValue
309+ valign = gtk.AlignStartValue
310+ case ToastPositionBottomLeft:
311+ halign = gtk.AlignStartValue
312+ valign = gtk.AlignEndValue
313+ case ToastPositionBottomCenter:
314+ halign = gtk.AlignCenterValue
315+ valign = gtk.AlignEndValue
316+ case ToastPositionBottomRight:
317+ halign = gtk.AlignEndValue
318+ valign = gtk.AlignEndValue
319+ default:
320+ halign = gtk.AlignStartValue
321+ valign = gtk.AlignStartValue
322+ }
323+
324+ t.container.SetHalign(halign)
325+ t.container.SetValign(valign)
326+}
327+
328+// applyCustomStyle applies custom background and text colors via inline CSS.
329+func (t *Toaster) applyCustomStyle(opts ToastOptions) {
330+ if opts.BackgroundColor == "" && opts.TextColor == "" {
331+ return
332+ }
333+
334+ // Add custom class to indicate custom styling is applied
335+ t.container.AddCssClass("toast-custom")
336+ t.hasCustomStyle = true
337+
338+ // Store the colors
339+ t.customBgColor = opts.BackgroundColor
340+ t.customTextColor = opts.TextColor
341+
342+ // Remove level-based styling when using custom colors
343+ if opts.BackgroundColor != "" {
344+ t.container.RemoveCssClass(t.currentLevel.cssClass())
345+ }
346+
347+ // Generate inline CSS for custom colors
348+ css := ".toast-custom {"
349+ if opts.BackgroundColor != "" {
350+ css += fmt.Sprintf(" background-color: %s;", opts.BackgroundColor)
351+ }
352+ if opts.TextColor != "" {
353+ css += fmt.Sprintf(" color: %s;", opts.TextColor)
354+ }
355+ css += " }"
356+
357+ // Create or reuse CSS provider for this widget
358+ if t.customCSSProvider == nil {
359+ t.customCSSProvider = gtk.NewCssProvider()
360+ }
361+ t.customCSSProvider.LoadFromString(css)
362+
363+ // Apply to widget's style context
364+ styleCtx := t.container.GtkWidget().GetStyleContext()
365+ if styleCtx != nil {
366+ styleCtx.AddProvider(t.customCSSProvider, uint(gtk.STYLE_PROVIDER_PRIORITY_APPLICATION+1))
367+ }
368+}
369+
370+// clearCustomStyle removes any custom styling applied.
371+func (t *Toaster) clearCustomStyle() {
372+ if t.hasCustomStyle {
373+ t.container.RemoveCssClass("toast-custom")
374+ // Remove mode-specific class if one was applied
375+ if t.customModeClass != "" {
376+ t.container.RemoveCssClass(t.customModeClass)
377+ t.customModeClass = ""
378+ }
379+ // Remove custom CSS provider if one was added
380+ if t.customCSSProvider != nil {
381+ styleCtx := t.container.GtkWidget().GetStyleContext()
382+ if styleCtx != nil {
383+ styleCtx.RemoveProvider(t.customCSSProvider)
384+ }
385+ }
386+ // Re-add level class if it was removed due to custom background
387+ if t.customBgColor != "" {
388+ t.container.AddCssClass(t.currentLevel.cssClass())
389+ }
390+ t.hasCustomStyle = false
391+ t.customBgColor = ""
392+ t.customTextColor = ""
393+ }
394+}
395+
396+// applyModeClassInternal applies mode class without locking (must be called with lock held).
397+func (t *Toaster) applyModeClassInternal(modeClass string) {
398+ // Remove level class and add mode class
399+ t.container.RemoveCssClass(t.currentLevel.cssClass())
400+ t.container.AddCssClass(modeClass)
401+ t.hasCustomStyle = true
402+ t.customModeClass = modeClass // Store class name for cleanup tracking
403+}
404+
405+// startDismissTimerWithDuration starts the auto-dismiss timer with the given duration.
175406 // Must be called with lock held.
176-func (t *Toaster) startDismissTimer(ctx context.Context) {
407+func (t *Toaster) startDismissTimerWithDuration(ctx context.Context, durationMs int) {
177408 log := logging.FromContext(ctx)
178409
179410 cb := glib.SourceFunc(func(_ uintptr) bool {
@@ -188,7 +419,7 @@ func (t *Toaster) startDismissTimer(ctx context.Context) {
188419 return false // Don't repeat
189420 })
190421
191- t.dismissTimer = glib.TimeoutAdd(toastDismissTimeoutMs, &cb, 0)
422+ t.dismissTimer = glib.TimeoutAdd(uint(durationMs), &cb, 0)
192423 }
193424
194425 // Widget returns the underlying widget for embedding in overlays.
@@ -48,6 +48,89 @@ 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+ // ModeClass is a CSS class for mode-specific styling (e.g., "toast-pane-mode").
82+ // When set, this class is applied atomically with Show() to avoid visual flicker.
83+ ModeClass string
84+}
85+
86+// ToastOption is a functional option for configuring toast display.
87+type ToastOption func(*ToastOptions)
88+
89+// WithDuration sets the auto-dismiss duration in milliseconds.
90+// Use 0 for persistent toasts that require manual dismissal.
91+func WithDuration(ms int) ToastOption {
92+ return func(o *ToastOptions) {
93+ o.Duration = ms
94+ }
95+}
96+
97+// WithBackgroundColor sets a custom background color (CSS color value).
98+func WithBackgroundColor(color string) ToastOption {
99+ return func(o *ToastOptions) {
100+ o.BackgroundColor = color
101+ }
102+}
103+
104+// WithTextColor sets a custom text color (CSS color value).
105+func WithTextColor(color string) ToastOption {
106+ return func(o *ToastOptions) {
107+ o.TextColor = color
108+ }
109+}
110+
111+// WithPosition sets the toast position on screen.
112+func WithPosition(pos ToastPosition) ToastOption {
113+ return func(o *ToastOptions) {
114+ o.Position = pos
115+ }
116+}
117+
118+// WithModeClass sets a mode-specific CSS class for styling.
119+// This is applied atomically within Show() to avoid visual flicker.
120+func WithModeClass(class string) ToastOption {
121+ return func(o *ToastOptions) {
122+ o.ModeClass = class
123+ }
124+}
125+
126+// defaultToastOptions returns the default toast options.
127+func defaultToastOptions() ToastOptions {
128+ return ToastOptions{
129+ Duration: toastDismissTimeoutMs,
130+ Position: ToastPositionTopLeft,
131+ }
132+}
133+
51 // Toaster displays toast notifications in an overlay.134 // Toaster displays toast notifications in an overlay.
52 // It supports different notification levels and auto-dismissal.135 // It supports different notification levels and auto-dismissal.
53 // When a new toast is shown while one is already visible, the text136 // When a new toast is shown while one is already visible, the text
@@ -60,18 +143,26 @@ type Toaster struct {
60 visible bool143 visible bool
61 dismissTimer uint // GLib timer source ID144 dismissTimer uint // GLib timer source ID
62 145
146+ // Track current options for cleanup
147+ currentOpts ToastOptions
148+ hasCustomStyle bool
149+ customBgColor string // Current custom background color
150+ customTextColor string // Current custom text color
151+ customModeClass string // Current mode-specific CSS class (e.g., "toast-pane-mode")
152+ customCSSProvider *gtk.CssProvider // CSS provider for inline custom colors
153+
63 mu sync.Mutex154 mu sync.Mutex
64 }155 }
65 156
66 // NewToaster creates a new toaster component for overlay display.157 // NewToaster creates a new toaster component for overlay display.
67-// The toaster is positioned in the top-left corner with margin.158+// The toaster is positioned in the top-left corner with margin by default.
68 func NewToaster(factory layout.WidgetFactory) *Toaster {159 func NewToaster(factory layout.WidgetFactory) *Toaster {
69 // Create container box for styling and positioning160 // Create container box for styling and positioning
70 container := factory.NewBox(layout.OrientationHorizontal, 0)161 container := factory.NewBox(layout.OrientationHorizontal, 0)
71 container.AddCssClass("toast")162 container.AddCssClass("toast")
72 container.AddCssClass("toast-info") // Default level163 container.AddCssClass("toast-info") // Default level
73 164
74- // Position in top-left with margin165+ // Position in top-left with margin (default)
75 container.SetHalign(gtk.AlignStartValue)166 container.SetHalign(gtk.AlignStartValue)
76 container.SetValign(gtk.AlignStartValue)167 container.SetValign(gtk.AlignStartValue)
77 168
@@ -98,17 +189,28 @@ func NewToaster(factory layout.WidgetFactory) *Toaster {
98 label: label,189 label: label,
99 currentLevel: ToastInfo,190 currentLevel: ToastInfo,
100 visible: false,191 visible: false,
192+ currentOpts: defaultToastOptions(),
101 }193 }
102 }194 }
103 195
104 // Show displays a toast notification with the given message and level.196 // 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.197 // 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) {198+// Optional ToastOption arguments can customize duration, colors, and position.
199+func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel, opts ...ToastOption) {
107 log := logging.FromContext(ctx)200 log := logging.FromContext(ctx)
108 201
202+ // Build options from defaults and overrides
203+ options := defaultToastOptions()
204+ for _, opt := range opts {
205+ opt(&options)
206+ }
207+
109 t.mu.Lock()208 t.mu.Lock()
110 defer t.mu.Unlock()209 defer t.mu.Unlock()
111 210
211+ // Clear previous custom styles if any
212+ t.clearCustomStyle()
213+
112 // Update level CSS class if changed214 // Update level CSS class if changed
113 if t.currentLevel != level {215 if t.currentLevel != level {
114 t.container.RemoveCssClass(t.currentLevel.cssClass())216 t.container.RemoveCssClass(t.currentLevel.cssClass())
@@ -116,6 +218,17 @@ func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel) {
116 t.currentLevel = level218 t.currentLevel = level
117 }219 }
118 220
221+ // Apply custom colors if specified
222+ t.applyCustomStyle(options)
223+
224+ // Apply mode class if specified (atomically to avoid flicker)
225+ if options.ModeClass != "" {
226+ t.applyModeClassInternal(options.ModeClass)
227+ }
228+
229+ // Apply position
230+ t.applyPosition(options.Position)
231+
119 // Update message text232 // Update message text
120 t.label.SetText(message)233 t.label.SetText(message)
121 234
@@ -131,12 +244,18 @@ func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel) {
131 t.container.SetVisible(true)244 t.container.SetVisible(true)
132 }245 }
133 246
134- // Start new dismiss timer247+ // Start new dismiss timer (if duration > 0)
135- t.startDismissTimer(ctx)248+ if options.Duration > 0 {
249+ t.startDismissTimerWithDuration(ctx, options.Duration)
250+ }
251+ // Duration == 0 means persistent, no timer
252+
253+ t.currentOpts = options
136 254
137 log.Debug().255 log.Debug().
138 Str("toast_message", message).256 Str("toast_message", message).
139 Int("toast_level", int(level)).257 Int("toast_level", int(level)).
258+ Int("duration", options.Duration).
140 Msg("toast shown")259 Msg("toast shown")
141 }260 }
142 261
@@ -167,13 +286,125 @@ func (t *Toaster) hide() {
167 t.dismissTimer = 0286 t.dismissTimer = 0
168 }287 }
169 288
289+ // Clear custom styles
290+ t.clearCustomStyle()
291+
170 t.visible = false292 t.visible = false
171 t.container.SetVisible(false)293 t.container.SetVisible(false)
172 }294 }
173 295
174-// startDismissTimer starts the auto-dismiss timer.296+// applyPosition sets the widget alignment based on the position.
297+func (t *Toaster) applyPosition(pos ToastPosition) {
298+ var halign, valign gtk.Align
299+
300+ switch pos {
301+ case ToastPositionTopLeft:
302+ halign = gtk.AlignStartValue
303+ valign = gtk.AlignStartValue
304+ case ToastPositionTopCenter:
305+ halign = gtk.AlignCenterValue
306+ valign = gtk.AlignStartValue
307+ case ToastPositionTopRight:
308+ halign = gtk.AlignEndValue
309+ valign = gtk.AlignStartValue
310+ case ToastPositionBottomLeft:
311+ halign = gtk.AlignStartValue
312+ valign = gtk.AlignEndValue
313+ case ToastPositionBottomCenter:
314+ halign = gtk.AlignCenterValue
315+ valign = gtk.AlignEndValue
316+ case ToastPositionBottomRight:
317+ halign = gtk.AlignEndValue
318+ valign = gtk.AlignEndValue
319+ default:
320+ halign = gtk.AlignStartValue
321+ valign = gtk.AlignStartValue
322+ }
323+
324+ t.container.SetHalign(halign)
325+ t.container.SetValign(valign)
326+}
327+
328+// applyCustomStyle applies custom background and text colors via inline CSS.
329+func (t *Toaster) applyCustomStyle(opts ToastOptions) {
330+ if opts.BackgroundColor == "" && opts.TextColor == "" {
331+ return
332+ }
333+
334+ // Add custom class to indicate custom styling is applied
335+ t.container.AddCssClass("toast-custom")
336+ t.hasCustomStyle = true
337+
338+ // Store the colors
339+ t.customBgColor = opts.BackgroundColor
340+ t.customTextColor = opts.TextColor
341+
342+ // Remove level-based styling when using custom colors
343+ if opts.BackgroundColor != "" {
344+ t.container.RemoveCssClass(t.currentLevel.cssClass())
345+ }
346+
347+ // Generate inline CSS for custom colors
348+ css := ".toast-custom {"
349+ if opts.BackgroundColor != "" {
350+ css += fmt.Sprintf(" background-color: %s;", opts.BackgroundColor)
351+ }
352+ if opts.TextColor != "" {
353+ css += fmt.Sprintf(" color: %s;", opts.TextColor)
354+ }
355+ css += " }"
356+
357+ // Create or reuse CSS provider for this widget
358+ if t.customCSSProvider == nil {
359+ t.customCSSProvider = gtk.NewCssProvider()
360+ }
361+ t.customCSSProvider.LoadFromString(css)
362+
363+ // Apply to widget's style context
364+ styleCtx := t.container.GtkWidget().GetStyleContext()
365+ if styleCtx != nil {
366+ styleCtx.AddProvider(t.customCSSProvider, uint(gtk.STYLE_PROVIDER_PRIORITY_APPLICATION+1))
367+ }
368+}
369+
370+// clearCustomStyle removes any custom styling applied.
371+func (t *Toaster) clearCustomStyle() {
372+ if t.hasCustomStyle {
373+ t.container.RemoveCssClass("toast-custom")
374+ // Remove mode-specific class if one was applied
375+ if t.customModeClass != "" {
376+ t.container.RemoveCssClass(t.customModeClass)
377+ t.customModeClass = ""
378+ }
379+ // Remove custom CSS provider if one was added
380+ if t.customCSSProvider != nil {
381+ styleCtx := t.container.GtkWidget().GetStyleContext()
382+ if styleCtx != nil {
383+ styleCtx.RemoveProvider(t.customCSSProvider)
384+ }
385+ }
386+ // Re-add level class if it was removed due to custom background
387+ if t.customBgColor != "" {
388+ t.container.AddCssClass(t.currentLevel.cssClass())
389+ }
390+ t.hasCustomStyle = false
391+ t.customBgColor = ""
392+ t.customTextColor = ""
393+ }
394+}
395+
396+// applyModeClassInternal applies mode class without locking (must be called with lock held).
397+func (t *Toaster) applyModeClassInternal(modeClass string) {
398+ // Remove level class and add mode class
399+ t.container.RemoveCssClass(t.currentLevel.cssClass())
400+ t.container.AddCssClass(modeClass)
401+ t.hasCustomStyle = true
402+ t.customModeClass = modeClass // Store class name for cleanup tracking
403+}
404+
405+// startDismissTimerWithDuration starts the auto-dismiss timer with the given duration.
175 // Must be called with lock held.406 // Must be called with lock held.
176-func (t *Toaster) startDismissTimer(ctx context.Context) {407+func (t *Toaster) startDismissTimerWithDuration(ctx context.Context, durationMs int) {
177 log := logging.FromContext(ctx)408 log := logging.FromContext(ctx)
178 409
179 cb := glib.SourceFunc(func(_ uintptr) bool {410 cb := glib.SourceFunc(func(_ uintptr) bool {
@@ -188,7 +419,7 @@ func (t *Toaster) startDismissTimer(ctx context.Context) {
188 return false // Don't repeat419 return false // Don't repeat
189 })420 })
190 421
191- t.dismissTimer = glib.TimeoutAdd(toastDismissTimeoutMs, &cb, 0)422+ t.dismissTimer = glib.TimeoutAdd(uint(durationMs), &cb, 0)
192 }423 }
193 424
194 // Widget returns the underlying widget for embedding in overlays.425 // 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