fix(daemon): preserve history navigation under code overrides
- commit
- 9edd30e1b861398f7b71b5f21b0a4b71af84c122
- author
- brice · · 2026-07-06 17:51 UTC
- parent
- 3b83428
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +53 -44
M internal/usecase/daemon/client.go +21 -19
| @@ -39,25 +39,27 @@ import ( | |||
| 39 | 39 | ) | |
| 40 | 40 | ||
| 41 | 41 | type attachedClient struct { | |
| 42 | - | tr ports.Transport | |
| 43 | - | rend *renderer.Renderer | |
| 44 | - | overlays *overlayRuntime | |
| 45 | - | clientID [16]byte | |
| 46 | - | resumeCapable bool | |
| 47 | - | resumeToken uint64 | |
| 48 | - | parked bool | |
| 49 | - | nextStateNum uint64 | |
| 50 | - | echoAck atomic.Uint64 | |
| 51 | - | bars barCache // only touched while sendMu is held | |
| 52 | - | size domain.Size | |
| 53 | - | keys *keys.Router | |
| 54 | - | sess Guarded[*session] | |
| 55 | - | mouseScan mouse.Scanner | |
| 56 | - | themeMu sync.Mutex | |
| 57 | - | theme themeui.Theme | |
| 58 | - | clientTheme themeui.Theme | |
| 59 | - | lastCursor cursorOut | |
| 60 | - | recentNav recentSessionNavigator | |
| 42 | + | tr ports.Transport | |
| 43 | + | rend *renderer.Renderer | |
| 44 | + | overlays *overlayRuntime | |
| 45 | + | clientID [16]byte | |
| 46 | + | resumeCapable bool | |
| 47 | + | resumeToken uint64 | |
| 48 | + | parked bool | |
| 49 | + | nextStateNum uint64 | |
| 50 | + | echoAck atomic.Uint64 | |
| 51 | + | bars barCache // only touched while sendMu is held | |
| 52 | + | size domain.Size | |
| 53 | + | keys *keys.Router | |
| 54 | + | sess Guarded[*session] | |
| 55 | + | mouseScan mouse.Scanner | |
| 56 | + | themeMu sync.Mutex | |
| 57 | + | theme themeui.Theme | |
| 58 | + | clientTheme themeui.Theme | |
| 59 | + | lastCursor cursorOut | |
| 60 | + | recentNav recentSessionNavigator | |
| 61 | + | // historyNavMu protects historyNav and historyNavTimer. When paint needs | |
| 62 | + | // several locks, take sendMu before historyNavMu, then Daemon.mu/session.mu. | |
| 61 | 63 | historyNavMu sync.Mutex | |
| 62 | 64 | historyNav historyNavDisplay | |
| 63 | 65 | historyNavTimer pendingByteTimer | |
| @@ -39,25 +39,27 @@ import ( | |||||
| 39 | ) | 39 | ) | ||
| 40 | 40 | ||||
| 41 | type attachedClient struct { | 41 | type attachedClient struct { | ||
| 42 | - | tr ports.Transport | 42 | + | tr ports.Transport |
| 43 | - | rend *renderer.Renderer | 43 | + | rend *renderer.Renderer |
| 44 | - | overlays *overlayRuntime | 44 | + | overlays *overlayRuntime |
| 45 | - | clientID [16]byte | 45 | + | clientID [16]byte |
| 46 | - | resumeCapable bool | 46 | + | resumeCapable bool |
| 47 | - | resumeToken uint64 | 47 | + | resumeToken uint64 |
| 48 | - | parked bool | 48 | + | parked bool |
| 49 | - | nextStateNum uint64 | 49 | + | nextStateNum uint64 |
| 50 | - | echoAck atomic.Uint64 | 50 | + | echoAck atomic.Uint64 |
| 51 | - | bars barCache // only touched while sendMu is held | 51 | + | bars barCache // only touched while sendMu is held |
| 52 | - | size domain.Size | 52 | + | size domain.Size |
| 53 | - | keys *keys.Router | 53 | + | keys *keys.Router |
| 54 | - | sess Guarded[*session] | 54 | + | sess Guarded[*session] |
| 55 | - | mouseScan mouse.Scanner | 55 | + | mouseScan mouse.Scanner |
| 56 | - | themeMu sync.Mutex | 56 | + | themeMu sync.Mutex |
| 57 | - | theme themeui.Theme | 57 | + | theme themeui.Theme |
| 58 | - | clientTheme themeui.Theme | 58 | + | clientTheme themeui.Theme |
| 59 | - | lastCursor cursorOut | 59 | + | lastCursor cursorOut |
| 60 | - | recentNav recentSessionNavigator | 60 | + | recentNav recentSessionNavigator |
| 61 | + | // historyNavMu protects historyNav and historyNavTimer. When paint needs | |||
| 62 | + | // several locks, take sendMu before historyNavMu, then Daemon.mu/session.mu. | |||
| 61 | historyNavMu sync.Mutex | 63 | historyNavMu sync.Mutex | ||
| 62 | historyNav historyNavDisplay | 64 | historyNav historyNavDisplay | ||
| 63 | historyNavTimer pendingByteTimer | 65 | historyNavTimer pendingByteTimer | ||
M internal/usecase/daemon/input_test.go +8 -0
| @@ -310,6 +310,14 @@ func TestHistoryNavDisplay(t *testing.T) { | |||
| 310 | 310 | require.Equal(t, uint64(7), h.gen) | |
| 311 | 311 | } | |
| 312 | 312 | ||
| 313 | + | func TestHistoryNavigationCommandDetectionUsesSlug(t *testing.T) { | |
| 314 | + | require.True(t, isHistoryNavigationCommand("back-session")) | |
| 315 | + | require.True(t, isHistoryNavigationCommand("forward-session")) | |
| 316 | + | require.False(t, isHistoryNavigationCommand("BSK")) | |
| 317 | + | require.False(t, isHistoryNavigationCommand("FSK")) | |
| 318 | + | require.False(t, isHistoryNavigationCommand("next-tab")) | |
| 319 | + | } | |
| 320 | + | ||
| 313 | 321 | func TestHistoryNavActivation(t *testing.T) { | |
| 314 | 322 | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | |
| 315 | 323 | defer releaseAll(releases) | |
| @@ -310,6 +310,14 @@ func TestHistoryNavDisplay(t *testing.T) { | |||||
| 310 | require.Equal(t, uint64(7), h.gen) | 310 | require.Equal(t, uint64(7), h.gen) | ||
| 311 | } | 311 | } | ||
| 312 | 312 | ||||
| 313 | + | func TestHistoryNavigationCommandDetectionUsesSlug(t *testing.T) { | |||
| 314 | + | require.True(t, isHistoryNavigationCommand("back-session")) | |||
| 315 | + | require.True(t, isHistoryNavigationCommand("forward-session")) | |||
| 316 | + | require.False(t, isHistoryNavigationCommand("BSK")) | |||
| 317 | + | require.False(t, isHistoryNavigationCommand("FSK")) | |||
| 318 | + | require.False(t, isHistoryNavigationCommand("next-tab")) | |||
| 319 | + | } | |||
| 320 | + | ||||
| 313 | func TestHistoryNavActivation(t *testing.T) { | 321 | func TestHistoryNavActivation(t *testing.T) { | ||
| 314 | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | 322 | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | ||
| 315 | defer releaseAll(releases) | 323 | defer releaseAll(releases) | ||
M internal/usecase/daemon/palette.go +3 -3
| @@ -122,7 +122,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 122 | 122 | ac.overlays.paletteMu.Unlock() | |
| 123 | 123 | ||
| 124 | 124 | if run && ok { | |
| 125 | - | if !isHistoryNavigationCommand(cmd.Code) { | |
| 125 | + | if !isHistoryNavigationCommand(cmd.Slug) { | |
| 126 | 126 | d.clearHistoryNav(ac) | |
| 127 | 127 | } | |
| 128 | 128 | if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}); err != nil { | |
| @@ -140,8 +140,8 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 140 | 140 | } | |
| 141 | 141 | } | |
| 142 | 142 | ||
| 143 | - | func isHistoryNavigationCommand(code string) bool { | |
| 144 | - | return code == "BSK" || code == "FSK" | |
| 143 | + | func isHistoryNavigationCommand(slug string) bool { | |
| 144 | + | return slug == "back-session" || slug == "forward-session" | |
| 145 | 145 | } | |
| 146 | 146 | ||
| 147 | 147 | type paletteExec struct { | |
| @@ -122,7 +122,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||||
| 122 | ac.overlays.paletteMu.Unlock() | 122 | ac.overlays.paletteMu.Unlock() | ||
| 123 | 123 | ||||
| 124 | if run && ok { | 124 | if run && ok { | ||
| 125 | - | if !isHistoryNavigationCommand(cmd.Code) { | 125 | + | if !isHistoryNavigationCommand(cmd.Slug) { |
| 126 | d.clearHistoryNav(ac) | 126 | d.clearHistoryNav(ac) | ||
| 127 | } | 127 | } | ||
| 128 | if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}); err != nil { | 128 | if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}); err != nil { | ||
| @@ -140,8 +140,8 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||||
| 140 | } | 140 | } | ||
| 141 | } | 141 | } | ||
| 142 | 142 | ||||
| 143 | - | func isHistoryNavigationCommand(code string) bool { | 143 | + | func isHistoryNavigationCommand(slug string) bool { |
| 144 | - | return code == "BSK" || code == "FSK" | 144 | + | return slug == "back-session" || slug == "forward-session" |
| 145 | } | 145 | } | ||
| 146 | 146 | ||||
| 147 | type paletteExec struct { | 147 | type paletteExec struct { | ||
M internal/usecase/daemon/recent_nav.go +3 -0
| @@ -120,6 +120,9 @@ func (d *Daemon) showHistoryNav(ac *attachedClient) { | |||
| 120 | 120 | } | |
| 121 | 121 | ||
| 122 | 122 | func (d *Daemon) clearHistoryNav(ac *attachedClient) { | |
| 123 | + | if ac == nil { | |
| 124 | + | return | |
| 125 | + | } | |
| 123 | 126 | ac.clearHistoryNav() | |
| 124 | 127 | } | |
| 125 | 128 | ||
| @@ -120,6 +120,9 @@ func (d *Daemon) showHistoryNav(ac *attachedClient) { | |||||
| 120 | } | 120 | } | ||
| 121 | 121 | ||||
| 122 | func (d *Daemon) clearHistoryNav(ac *attachedClient) { | 122 | func (d *Daemon) clearHistoryNav(ac *attachedClient) { | ||
| 123 | + | if ac == nil { | |||
| 124 | + | return | |||
| 125 | + | } | |||
| 123 | ac.clearHistoryNav() | 126 | ac.clearHistoryNav() | ||
| 124 | } | 127 | } | ||
| 125 | 128 | ||||
M internal/usecase/daemon/status.go +18 -22
| @@ -19,9 +19,11 @@ | |||
| 19 | 19 | // | |
| 20 | 20 | // Locking: a pane's screen/scrollback and per-client renderer shadow are | |
| 21 | 21 | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | |
| 22 | - | // session.mu; the registry by Daemon.mu. When more than one is held the order | |
| 23 | - | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |
| 24 | - | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. | |
| 22 | + | // session.mu; the registry by Daemon.mu. History-navigation display state is | |
| 23 | + | // guarded by attachedClient.historyNavMu. When more than one is held the order | |
| 24 | + | // is always attachedClient.sendMu > attachedClient.historyNavMu > Daemon.mu > | |
| 25 | + | // session.mu > tab.mu > pane.mu. The PTY reader only ever takes pane.mu, so it | |
| 26 | + | // never blocks on a slow client. | |
| 25 | 27 | package daemon | |
| 26 | 28 | ||
| 27 | 29 | import ( | |
| @@ -91,16 +93,7 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) | |||
| 91 | 93 | fittedMRU := fitMRU(state.mru, len(row), x, rightText) | |
| 92 | 94 | for i, sess := range fittedMRU { | |
| 93 | 95 | style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU)) | |
| 94 | - | name := sess.name | |
| 95 | - | if sess.ephemeral { | |
| 96 | - | name += "*" | |
| 97 | - | } | |
| 98 | - | writeStatusText(row, &x, " "+name, style) | |
| 99 | - | if sess.attention { | |
| 100 | - | writeStatusText(row, &x, " ", style) | |
| 101 | - | writeBell(row, &x, state.attentionFrame) | |
| 102 | - | } | |
| 103 | - | writeStatusText(row, &x, " ", style) | |
| 96 | + | drawStatusSessionEntry(row, &x, sess.name, sess.ephemeral, sess.attention, style, state.attentionFrame) | |
| 104 | 97 | } | |
| 105 | 98 | drawRightPlainText(row, rightText, x, styles.statusBar) | |
| 106 | 99 | } | |
| @@ -111,17 +104,20 @@ func drawHistoryNavSessions(row []renderer.Cell, x *int, entries []historyNavSes | |||
| 111 | 104 | if sess.active { | |
| 112 | 105 | style = styles.accent | |
| 113 | 106 | } | |
| 114 | - | name := sess.name | |
| 115 | - | if sess.ephemeral { | |
| 116 | - | name += "*" | |
| 117 | - | } | |
| 118 | - | writeStatusText(row, x, " "+name, style) | |
| 119 | - | if sess.attention { | |
| 120 | - | writeStatusText(row, x, " ", style) | |
| 121 | - | writeBell(row, x, attentionFrame) | |
| 122 | - | } | |
| 107 | + | drawStatusSessionEntry(row, x, sess.name, sess.ephemeral, sess.attention, style, attentionFrame) | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func drawStatusSessionEntry(row []renderer.Cell, x *int, name string, ephemeral, attention bool, style renderer.Style, attentionFrame int) { | |
| 112 | + | if ephemeral { | |
| 113 | + | name += "*" | |
| 114 | + | } | |
| 115 | + | writeStatusText(row, x, " "+name, style) | |
| 116 | + | if attention { | |
| 123 | 117 | writeStatusText(row, x, " ", style) | |
| 118 | + | writeBell(row, x, attentionFrame) | |
| 124 | 119 | } | |
| 120 | + | writeStatusText(row, x, " ", style) | |
| 125 | 121 | } | |
| 126 | 122 | ||
| 127 | 123 | func composeBottomRightText(scriptText, copyFeedback string) string { | |
| @@ -19,9 +19,11 @@ | |||||
| 19 | // | 19 | // | ||
| 20 | // Locking: a pane's screen/scrollback and per-client renderer shadow are | 20 | // Locking: a pane's screen/scrollback and per-client renderer shadow are | ||
| 21 | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | 21 | // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by | ||
| 22 | - | // session.mu; the registry by Daemon.mu. When more than one is held the order | 22 | + | // session.mu; the registry by Daemon.mu. History-navigation display state is |
| 23 | - | // is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | 23 | + | // guarded by attachedClient.historyNavMu. When more than one is held the order |
| 24 | - | // The PTY reader only ever takes pane.mu, so it never blocks on a slow client. | 24 | + | // is always attachedClient.sendMu > attachedClient.historyNavMu > Daemon.mu > |
| 25 | + | // session.mu > tab.mu > pane.mu. The PTY reader only ever takes pane.mu, so it | |||
| 26 | + | // never blocks on a slow client. | |||
| 25 | package daemon | 27 | package daemon | ||
| 26 | 28 | ||||
| 27 | import ( | 29 | import ( | ||
| @@ -91,16 +93,7 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) | |||||
| 91 | fittedMRU := fitMRU(state.mru, len(row), x, rightText) | 93 | fittedMRU := fitMRU(state.mru, len(row), x, rightText) | ||
| 92 | for i, sess := range fittedMRU { | 94 | for i, sess := range fittedMRU { | ||
| 93 | style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU)) | 95 | style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU)) | ||
| 94 | - | name := sess.name | 96 | + | drawStatusSessionEntry(row, &x, sess.name, sess.ephemeral, sess.attention, style, state.attentionFrame) |
| 95 | - | if sess.ephemeral { | |||
| 96 | - | name += "*" | |||
| 97 | - | } | |||
| 98 | - | writeStatusText(row, &x, " "+name, style) | |||
| 99 | - | if sess.attention { | |||
| 100 | - | writeStatusText(row, &x, " ", style) | |||
| 101 | - | writeBell(row, &x, state.attentionFrame) | |||
| 102 | - | } | |||
| 103 | - | writeStatusText(row, &x, " ", style) | |||
| 104 | } | 97 | } | ||
| 105 | drawRightPlainText(row, rightText, x, styles.statusBar) | 98 | drawRightPlainText(row, rightText, x, styles.statusBar) | ||
| 106 | } | 99 | } | ||
| @@ -111,17 +104,20 @@ func drawHistoryNavSessions(row []renderer.Cell, x *int, entries []historyNavSes | |||||
| 111 | if sess.active { | 104 | if sess.active { | ||
| 112 | style = styles.accent | 105 | style = styles.accent | ||
| 113 | } | 106 | } | ||
| 114 | - | name := sess.name | 107 | + | drawStatusSessionEntry(row, x, sess.name, sess.ephemeral, sess.attention, style, attentionFrame) |
| 115 | - | if sess.ephemeral { | 108 | + | } |
| 116 | - | name += "*" | 109 | + | } |
| 117 | - | } | 110 | + | |
| 118 | - | writeStatusText(row, x, " "+name, style) | 111 | + | func drawStatusSessionEntry(row []renderer.Cell, x *int, name string, ephemeral, attention bool, style renderer.Style, attentionFrame int) { |
| 119 | - | if sess.attention { | 112 | + | if ephemeral { |
| 120 | - | writeStatusText(row, x, " ", style) | 113 | + | name += "*" |
| 121 | - | writeBell(row, x, attentionFrame) | 114 | + | } |
| 122 | - | } | 115 | + | writeStatusText(row, x, " "+name, style) |
| 116 | + | if attention { | |||
| 123 | writeStatusText(row, x, " ", style) | 117 | writeStatusText(row, x, " ", style) | ||
| 118 | + | writeBell(row, x, attentionFrame) | |||
| 124 | } | 119 | } | ||
| 120 | + | writeStatusText(row, x, " ", style) | |||
| 125 | } | 121 | } | ||
| 126 | 122 | ||||
| 127 | func composeBottomRightText(scriptText, copyFeedback string) string { | 123 | func composeBottomRightText(scriptText, copyFeedback string) string { | ||