detached ef2e110

Merge pull request #31 from bnema/feat/fsk-bsk-history-nav-mode

feat(daemon): stabilize FSK/BSK history navigation
commit
ef2e110ede19aac3fbec9b13c27673b51e660fe3
author
bnema · · 2026-07-06 18:24 UTC
committer
GitHub · · 2026-07-06 18:24 UTC
merge of
7447d40 + 9edd30e · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
9 files · +541 -20
M internal/usecase/command/registry.go +2 -2
@@ -21,8 +21,8 @@ func Registry() []Command {
2121 {Slug: "focus-pane-down", Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context) error { return ctx.FocusPaneDown() }},
2222 {Slug: "next-tab", Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }},
2323 {Slug: "previous-tab", Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }},
24- {Slug: "back-session", Code: "BSK", Name: "Back session", Desc: "Switch back through recent sessions", Run: func(ctx Context) error { return ctx.BackSession() }},
25- {Slug: "forward-session", Code: "FSK", Name: "Forward session", Desc: "Switch forward through recent sessions", Run: func(ctx Context) error { return ctx.ForwardSession() }},
24+ {Slug: "back-session", Code: "BSK", Name: "Back session", Desc: "Step backward through recent-session history", Run: func(ctx Context) error { return ctx.BackSession() }},
25+ {Slug: "forward-session", Code: "FSK", Name: "Forward session", Desc: "Step forward through recent-session history", Run: func(ctx Context) error { return ctx.ForwardSession() }},
2626 {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
2727 {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},
2828 {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},
@@ -21,8 +21,8 @@ func Registry() []Command {
21 {Slug: "focus-pane-down", Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context) error { return ctx.FocusPaneDown() }},21 {Slug: "focus-pane-down", Code: "FPD", Name: "Focus pane down", Desc: "Focus the pane below", Run: func(ctx Context) error { return ctx.FocusPaneDown() }},
22 {Slug: "next-tab", Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }},22 {Slug: "next-tab", Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }},
23 {Slug: "previous-tab", Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }},23 {Slug: "previous-tab", Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }},
24- {Slug: "back-session", Code: "BSK", Name: "Back session", Desc: "Switch back through recent sessions", Run: func(ctx Context) error { return ctx.BackSession() }},24+ {Slug: "back-session", Code: "BSK", Name: "Back session", Desc: "Step backward through recent-session history", Run: func(ctx Context) error { return ctx.BackSession() }},
25- {Slug: "forward-session", Code: "FSK", Name: "Forward session", Desc: "Switch forward through recent sessions", Run: func(ctx Context) error { return ctx.ForwardSession() }},25+ {Slug: "forward-session", Code: "FSK", Name: "Forward session", Desc: "Step forward through recent-session history", Run: func(ctx Context) error { return ctx.ForwardSession() }},
26 {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},26 {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
27 {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},27 {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},
28 {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},28 {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},
M internal/usecase/daemon/client.go +24 -3
@@ -58,8 +58,13 @@ type attachedClient struct {
5858 clientTheme themeui.Theme
5959 lastCursor cursorOut
6060 recentNav recentSessionNavigator
61- linkMu sync.Mutex
62- sendMu sync.Mutex
61+ // historyNavMu protects historyNav and historyNavTimer. When paint needs
62+ // several locks, take sendMu before historyNavMu, then Daemon.mu/session.mu.
63+ historyNavMu sync.Mutex
64+ historyNav historyNavDisplay
65+ historyNavTimer pendingByteTimer
66+ linkMu sync.Mutex
67+ sendMu sync.Mutex
6368 }
6469
6570 type cursorOut struct {
@@ -108,7 +113,23 @@ func (ac *attachedClient) initOverlays() {
108113
109114 func (ac *attachedClient) currentSession() *session { return ac.sess.Get() }
110115
111-func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }
116+func (ac *attachedClient) setSession(sess *session) {
117+ if sess == nil {
118+ ac.clearHistoryNav()
119+ }
120+ ac.sess.Set(sess)
121+}
122+
123+func (ac *attachedClient) clearHistoryNav() {
124+ if ac == nil {
125+ return
126+ }
127+ ac.historyNavMu.Lock()
128+ ac.historyNav.gen++
129+ ac.historyNavTimer.stop()
130+ ac.historyNav.clear()
131+ ac.historyNavMu.Unlock()
132+}
112133
113134 func (ac *attachedClient) getTheme() themeui.Theme {
114135 ac.themeMu.Lock()
@@ -58,8 +58,13 @@ type attachedClient struct {
58 clientTheme themeui.Theme58 clientTheme themeui.Theme
59 lastCursor cursorOut59 lastCursor cursorOut
60 recentNav recentSessionNavigator60 recentNav recentSessionNavigator
61- linkMu sync.Mutex61+ // historyNavMu protects historyNav and historyNavTimer. When paint needs
62- sendMu sync.Mutex62+ // several locks, take sendMu before historyNavMu, then Daemon.mu/session.mu.
63+ historyNavMu sync.Mutex
64+ historyNav historyNavDisplay
65+ historyNavTimer pendingByteTimer
66+ linkMu sync.Mutex
67+ sendMu sync.Mutex
63 }68 }
64 69
65 type cursorOut struct {70 type cursorOut struct {
@@ -108,7 +113,23 @@ func (ac *attachedClient) initOverlays() {
108 113
109 func (ac *attachedClient) currentSession() *session { return ac.sess.Get() }114 func (ac *attachedClient) currentSession() *session { return ac.sess.Get() }
110 115
111-func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }116+func (ac *attachedClient) setSession(sess *session) {
117+ if sess == nil {
118+ ac.clearHistoryNav()
119+ }
120+ ac.sess.Set(sess)
121+}
122+
123+func (ac *attachedClient) clearHistoryNav() {
124+ if ac == nil {
125+ return
126+ }
127+ ac.historyNavMu.Lock()
128+ ac.historyNav.gen++
129+ ac.historyNavTimer.stop()
130+ ac.historyNav.clear()
131+ ac.historyNavMu.Unlock()
132+}
112 133
113 func (ac *attachedClient) getTheme() themeui.Theme {134 func (ac *attachedClient) getTheme() themeui.Theme {
114 ac.themeMu.Lock()135 ac.themeMu.Lock()
M internal/usecase/daemon/input_test.go +218 -0
@@ -287,6 +287,224 @@ func TestPaletteBackForwardSessionBoundaries(t *testing.T) {
287287 })
288288 }
289289
290+func TestHistoryNavDisplay(t *testing.T) {
291+ ids := []domain.SessionID{"current", "recent", "older"}
292+ var h historyNavDisplay
293+
294+ h.set(ids, 1, 7)
295+ ids[1] = "mutated"
296+ require.True(t, h.active())
297+ require.Equal(t, []domain.SessionID{domain.SessionID("current"), domain.SessionID("recent"), domain.SessionID("older")}, h.ids)
298+ require.Equal(t, 1, h.index)
299+ require.Equal(t, uint64(7), h.gen)
300+
301+ h.index = -1
302+ require.False(t, h.active())
303+ h.index = len(h.ids)
304+ require.False(t, h.active())
305+
306+ h.clear()
307+ require.False(t, h.active())
308+ require.Empty(t, h.ids)
309+ require.Zero(t, h.index)
310+ require.Equal(t, uint64(7), h.gen)
311+}
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+
321+func TestHistoryNavActivation(t *testing.T) {
322+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
323+ defer releaseAll(releases)
324+ recent := d.sessions[domain.SessionID("recent")]
325+ older := d.sessions[domain.SessionID("older")]
326+
327+ runPaletteCommand(t, d, current, ac, "BSK")
328+ require.Same(t, recent, ac.currentSession())
329+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
330+ require.GreaterOrEqual(t, drainOutputFrames(sends), 2)
331+
332+ runPaletteCommand(t, d, recent, ac, "BSK")
333+ require.Same(t, older, ac.currentSession())
334+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 2)
335+
336+ runPaletteCommand(t, d, older, ac, "FSK")
337+ require.Same(t, recent, ac.currentSession())
338+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
339+}
340+
341+func TestHistoryNavBoundaryDoesNotActivate(t *testing.T) {
342+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
343+ defer releaseAll(releases)
344+
345+ runPaletteCommand(t, d, current, ac, "FSK")
346+
347+ require.Same(t, current, ac.currentSession())
348+ ac.historyNavMu.Lock()
349+ active := ac.historyNav.active()
350+ ac.historyNavMu.Unlock()
351+ require.False(t, active)
352+}
353+
354+func TestHistoryNavTimeoutExpires(t *testing.T) {
355+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
356+ defer releaseAll(releases)
357+ clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 1)}
358+ d.clock = clock
359+
360+ runPaletteCommand(t, d, current, ac, "BSK")
361+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
362+ timer := <-clock.timers
363+ drainOutputFrames(sends)
364+ timer.fire()
365+
366+ require.Eventually(t, func() bool {
367+ ac.historyNavMu.Lock()
368+ defer ac.historyNavMu.Unlock()
369+ return !ac.historyNav.active()
370+ }, time.Second, time.Millisecond)
371+ require.Eventually(t, func() bool { return drainOutputFrames(sends) > 0 }, time.Second, time.Millisecond)
372+}
373+
374+func TestHistoryNavStaleTimerCannotClearFreshActivation(t *testing.T) {
375+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
376+ defer releaseAll(releases)
377+ clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 2)}
378+ d.clock = clock
379+
380+ runPaletteCommand(t, d, current, ac, "BSK")
381+ staleTimer := <-clock.timers
382+ ac.clearHistoryNav()
383+ runPaletteCommand(t, d, ac.currentSession(), ac, "FSK")
384+ <-clock.timers
385+
386+ staleTimer.fire()
387+
388+ require.Eventually(t, func() bool {
389+ ac.historyNavMu.Lock()
390+ defer ac.historyNavMu.Unlock()
391+ return ac.historyNav.active() && ac.historyNav.index == 0 && ac.historyNav.gen > 1
392+ }, time.Second, time.Millisecond)
393+}
394+
395+func TestHistoryNavTimerStoppedOnDetachAndReplacement(t *testing.T) {
396+ t.Run("detach stops stale timer repaint", func(t *testing.T) {
397+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
398+ defer releaseAll(releases)
399+ clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 1)}
400+ d.clock = clock
401+
402+ runPaletteCommand(t, d, current, ac, "BSK")
403+ timer := <-clock.timers
404+ recent := ac.currentSession()
405+ d.clientGone(recent, ac, ac.transport(), true)
406+ drainOutputFrames(sends)
407+ timer.fire()
408+
409+ requireNoHistoryNav(t, ac)
410+ require.Never(t, func() bool { return drainOutputFrames(sends) > 0 }, 25*time.Millisecond, time.Millisecond)
411+ })
412+
413+ t.Run("replacement stops stale timer repaint", func(t *testing.T) {
414+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
415+ defer releaseAll(releases)
416+ clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 1)}
417+ d.clock = clock
418+
419+ runPaletteCommand(t, d, current, ac, "BSK")
420+ timer := <-clock.timers
421+ d.detachReplacedClient(ac)
422+ drainOutputFrames(sends)
423+ timer.fire()
424+
425+ requireNoHistoryNav(t, ac)
426+ require.Never(t, func() bool { return drainOutputFrames(sends) > 0 }, 25*time.Millisecond, time.Millisecond)
427+ })
428+}
429+
430+func TestHistoryNavClearsOnInvalidTrailAndNonHistorySwitch(t *testing.T) {
431+ t.Run("invalid frozen trail clears", func(t *testing.T) {
432+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
433+ defer releaseAll(releases)
434+
435+ runPaletteCommand(t, d, current, ac, "BSK")
436+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
437+ d.mu.Lock()
438+ delete(d.sessions, domain.SessionID("older"))
439+ d.mu.Unlock()
440+
441+ require.Nil(t, d.historyNavBarState(ac))
442+ requireNoHistoryNav(t, ac)
443+ })
444+
445+ t.Run("non history palette command clears frozen trail", func(t *testing.T) {
446+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
447+ defer releaseAll(releases)
448+
449+ runPaletteCommand(t, d, current, ac, "BSK")
450+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
451+ runPaletteCommand(t, d, ac.currentSession(), ac, "NXT")
452+
453+ requireNoHistoryNav(t, ac)
454+ })
455+}
456+
457+func requireHistoryNav(t *testing.T, ac *attachedClient, want []domain.SessionID, wantIndex int) {
458+ t.Helper()
459+ ac.historyNavMu.Lock()
460+ defer ac.historyNavMu.Unlock()
461+ require.True(t, ac.historyNav.active())
462+ require.Equal(t, want, ac.historyNav.ids)
463+ require.Equal(t, wantIndex, ac.historyNav.index)
464+}
465+
466+func requireNoHistoryNav(t *testing.T, ac *attachedClient) {
467+ t.Helper()
468+ ac.historyNavMu.Lock()
469+ defer ac.historyNavMu.Unlock()
470+ require.False(t, ac.historyNav.active())
471+}
472+
473+func drainOutputFrames(sends chan ports.Frame) int {
474+ count := 0
475+ for {
476+ select {
477+ case f := <-sends:
478+ if f.Type == ports.MsgOutput {
479+ count++
480+ }
481+ default:
482+ return count
483+ }
484+ }
485+}
486+
487+type historyNavTestClock struct {
488+ timers chan *historyNavTestTimer
489+}
490+
491+func (c *historyNavTestClock) Now() time.Time { return time.Time{} }
492+
493+func (c *historyNavTestClock) NewTimer(time.Duration) ports.Timer {
494+ t := &historyNavTestTimer{ch: make(chan time.Time, 1)}
495+ c.timers <- t
496+ return t
497+}
498+
499+type historyNavTestTimer struct {
500+ ch chan time.Time
501+}
502+
503+func (t *historyNavTestTimer) C() <-chan time.Time { return t.ch }
504+func (t *historyNavTestTimer) Reset(time.Duration) bool { return false }
505+func (t *historyNavTestTimer) Stop() bool { return true }
506+func (t *historyNavTestTimer) fire() { t.ch <- time.Time{} }
507+
290508 func TestPaletteBackForwardSessionStaleTrail(t *testing.T) {
291509 t.Run("forward skips a killed intermediate session", func(t *testing.T) {
292510 d, current, ac, _, releases := newRecentNavigationTestSessions(t)
@@ -287,6 +287,224 @@ func TestPaletteBackForwardSessionBoundaries(t *testing.T) {
287 })287 })
288 }288 }
289 289
290+func TestHistoryNavDisplay(t *testing.T) {
291+ ids := []domain.SessionID{"current", "recent", "older"}
292+ var h historyNavDisplay
293+
294+ h.set(ids, 1, 7)
295+ ids[1] = "mutated"
296+ require.True(t, h.active())
297+ require.Equal(t, []domain.SessionID{domain.SessionID("current"), domain.SessionID("recent"), domain.SessionID("older")}, h.ids)
298+ require.Equal(t, 1, h.index)
299+ require.Equal(t, uint64(7), h.gen)
300+
301+ h.index = -1
302+ require.False(t, h.active())
303+ h.index = len(h.ids)
304+ require.False(t, h.active())
305+
306+ h.clear()
307+ require.False(t, h.active())
308+ require.Empty(t, h.ids)
309+ require.Zero(t, h.index)
310+ require.Equal(t, uint64(7), h.gen)
311+}
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+
321+func TestHistoryNavActivation(t *testing.T) {
322+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
323+ defer releaseAll(releases)
324+ recent := d.sessions[domain.SessionID("recent")]
325+ older := d.sessions[domain.SessionID("older")]
326+
327+ runPaletteCommand(t, d, current, ac, "BSK")
328+ require.Same(t, recent, ac.currentSession())
329+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
330+ require.GreaterOrEqual(t, drainOutputFrames(sends), 2)
331+
332+ runPaletteCommand(t, d, recent, ac, "BSK")
333+ require.Same(t, older, ac.currentSession())
334+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 2)
335+
336+ runPaletteCommand(t, d, older, ac, "FSK")
337+ require.Same(t, recent, ac.currentSession())
338+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
339+}
340+
341+func TestHistoryNavBoundaryDoesNotActivate(t *testing.T) {
342+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
343+ defer releaseAll(releases)
344+
345+ runPaletteCommand(t, d, current, ac, "FSK")
346+
347+ require.Same(t, current, ac.currentSession())
348+ ac.historyNavMu.Lock()
349+ active := ac.historyNav.active()
350+ ac.historyNavMu.Unlock()
351+ require.False(t, active)
352+}
353+
354+func TestHistoryNavTimeoutExpires(t *testing.T) {
355+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
356+ defer releaseAll(releases)
357+ clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 1)}
358+ d.clock = clock
359+
360+ runPaletteCommand(t, d, current, ac, "BSK")
361+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
362+ timer := <-clock.timers
363+ drainOutputFrames(sends)
364+ timer.fire()
365+
366+ require.Eventually(t, func() bool {
367+ ac.historyNavMu.Lock()
368+ defer ac.historyNavMu.Unlock()
369+ return !ac.historyNav.active()
370+ }, time.Second, time.Millisecond)
371+ require.Eventually(t, func() bool { return drainOutputFrames(sends) > 0 }, time.Second, time.Millisecond)
372+}
373+
374+func TestHistoryNavStaleTimerCannotClearFreshActivation(t *testing.T) {
375+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
376+ defer releaseAll(releases)
377+ clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 2)}
378+ d.clock = clock
379+
380+ runPaletteCommand(t, d, current, ac, "BSK")
381+ staleTimer := <-clock.timers
382+ ac.clearHistoryNav()
383+ runPaletteCommand(t, d, ac.currentSession(), ac, "FSK")
384+ <-clock.timers
385+
386+ staleTimer.fire()
387+
388+ require.Eventually(t, func() bool {
389+ ac.historyNavMu.Lock()
390+ defer ac.historyNavMu.Unlock()
391+ return ac.historyNav.active() && ac.historyNav.index == 0 && ac.historyNav.gen > 1
392+ }, time.Second, time.Millisecond)
393+}
394+
395+func TestHistoryNavTimerStoppedOnDetachAndReplacement(t *testing.T) {
396+ t.Run("detach stops stale timer repaint", func(t *testing.T) {
397+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
398+ defer releaseAll(releases)
399+ clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 1)}
400+ d.clock = clock
401+
402+ runPaletteCommand(t, d, current, ac, "BSK")
403+ timer := <-clock.timers
404+ recent := ac.currentSession()
405+ d.clientGone(recent, ac, ac.transport(), true)
406+ drainOutputFrames(sends)
407+ timer.fire()
408+
409+ requireNoHistoryNav(t, ac)
410+ require.Never(t, func() bool { return drainOutputFrames(sends) > 0 }, 25*time.Millisecond, time.Millisecond)
411+ })
412+
413+ t.Run("replacement stops stale timer repaint", func(t *testing.T) {
414+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
415+ defer releaseAll(releases)
416+ clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 1)}
417+ d.clock = clock
418+
419+ runPaletteCommand(t, d, current, ac, "BSK")
420+ timer := <-clock.timers
421+ d.detachReplacedClient(ac)
422+ drainOutputFrames(sends)
423+ timer.fire()
424+
425+ requireNoHistoryNav(t, ac)
426+ require.Never(t, func() bool { return drainOutputFrames(sends) > 0 }, 25*time.Millisecond, time.Millisecond)
427+ })
428+}
429+
430+func TestHistoryNavClearsOnInvalidTrailAndNonHistorySwitch(t *testing.T) {
431+ t.Run("invalid frozen trail clears", func(t *testing.T) {
432+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
433+ defer releaseAll(releases)
434+
435+ runPaletteCommand(t, d, current, ac, "BSK")
436+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
437+ d.mu.Lock()
438+ delete(d.sessions, domain.SessionID("older"))
439+ d.mu.Unlock()
440+
441+ require.Nil(t, d.historyNavBarState(ac))
442+ requireNoHistoryNav(t, ac)
443+ })
444+
445+ t.Run("non history palette command clears frozen trail", func(t *testing.T) {
446+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
447+ defer releaseAll(releases)
448+
449+ runPaletteCommand(t, d, current, ac, "BSK")
450+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
451+ runPaletteCommand(t, d, ac.currentSession(), ac, "NXT")
452+
453+ requireNoHistoryNav(t, ac)
454+ })
455+}
456+
457+func requireHistoryNav(t *testing.T, ac *attachedClient, want []domain.SessionID, wantIndex int) {
458+ t.Helper()
459+ ac.historyNavMu.Lock()
460+ defer ac.historyNavMu.Unlock()
461+ require.True(t, ac.historyNav.active())
462+ require.Equal(t, want, ac.historyNav.ids)
463+ require.Equal(t, wantIndex, ac.historyNav.index)
464+}
465+
466+func requireNoHistoryNav(t *testing.T, ac *attachedClient) {
467+ t.Helper()
468+ ac.historyNavMu.Lock()
469+ defer ac.historyNavMu.Unlock()
470+ require.False(t, ac.historyNav.active())
471+}
472+
473+func drainOutputFrames(sends chan ports.Frame) int {
474+ count := 0
475+ for {
476+ select {
477+ case f := <-sends:
478+ if f.Type == ports.MsgOutput {
479+ count++
480+ }
481+ default:
482+ return count
483+ }
484+ }
485+}
486+
487+type historyNavTestClock struct {
488+ timers chan *historyNavTestTimer
489+}
490+
491+func (c *historyNavTestClock) Now() time.Time { return time.Time{} }
492+
493+func (c *historyNavTestClock) NewTimer(time.Duration) ports.Timer {
494+ t := &historyNavTestTimer{ch: make(chan time.Time, 1)}
495+ c.timers <- t
496+ return t
497+}
498+
499+type historyNavTestTimer struct {
500+ ch chan time.Time
501+}
502+
503+func (t *historyNavTestTimer) C() <-chan time.Time { return t.ch }
504+func (t *historyNavTestTimer) Reset(time.Duration) bool { return false }
505+func (t *historyNavTestTimer) Stop() bool { return true }
506+func (t *historyNavTestTimer) fire() { t.ch <- time.Time{} }
507+
290 func TestPaletteBackForwardSessionStaleTrail(t *testing.T) {508 func TestPaletteBackForwardSessionStaleTrail(t *testing.T) {
291 t.Run("forward skips a killed intermediate session", func(t *testing.T) {509 t.Run("forward skips a killed intermediate session", func(t *testing.T) {
292 d, current, ac, _, releases := newRecentNavigationTestSessions(t)510 d, current, ac, _, releases := newRecentNavigationTestSessions(t)
M internal/usecase/daemon/palette.go +7 -0
@@ -122,6 +122,9 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
122122 ac.overlays.paletteMu.Unlock()
123123
124124 if run && ok {
125+ if !isHistoryNavigationCommand(cmd.Slug) {
126+ d.clearHistoryNav(ac)
127+ }
125128 if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}); err != nil {
126129 sess.mu.Lock()
127130 name := sess.name
@@ -137,6 +140,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
137140 }
138141 }
139142
143+func isHistoryNavigationCommand(slug string) bool {
144+ return slug == "back-session" || slug == "forward-session"
145+}
146+
140147 type paletteExec struct {
141148 d *Daemon
142149 sess *session
@@ -122,6 +122,9 @@ 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.Slug) {
126+ d.clearHistoryNav(ac)
127+ }
125 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 {
126 sess.mu.Lock()129 sess.mu.Lock()
127 name := sess.name130 name := sess.name
@@ -137,6 +140,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
137 }140 }
138 }141 }
139 142
143+func isHistoryNavigationCommand(slug string) bool {
144+ return slug == "back-session" || slug == "forward-session"
145+}
146+
140 type paletteExec struct {147 type paletteExec struct {
141 d *Daemon148 d *Daemon
142 sess *session149 sess *session
M internal/usecase/daemon/picker.go +1 -0
@@ -350,6 +350,7 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
350350 }
351351
352352 func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) {
353+ d.clearHistoryNav(ac)
353354 if target.Stopped {
354355 d.resumeStoppedAndSwitch(sess, ac, target)
355356 return
@@ -350,6 +350,7 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
350 }350 }
351 351
352 func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) {352 func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) {
353+ d.clearHistoryNav(ac)
353 if target.Stopped {354 if target.Stopped {
354 d.resumeStoppedAndSwitch(sess, ac, target)355 d.resumeStoppedAndSwitch(sess, ac, target)
355 return356 return
M internal/usecase/daemon/recent_nav.go +68 -0
@@ -1,15 +1,45 @@
11 package daemon
22
33 import (
4+ "time"
5+
46 "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
58 "github.com/bnema/vev/internal/usecase/picker"
69 )
710
11+const historyNavDisplayTimeout = 1500 * time.Millisecond
12+
813 type recentSessionNavigator struct {
914 ids []domain.SessionID
1015 index int
1116 }
1217
18+type historyNavDisplay struct {
19+ ids []domain.SessionID
20+ index int
21+ gen uint64
22+}
23+
24+func (h *historyNavDisplay) set(ids []domain.SessionID, index int, gen uint64) {
25+ h.ids = append(h.ids[:0], ids...)
26+ h.index = index
27+ h.gen = gen
28+}
29+
30+func (h *historyNavDisplay) clear() {
31+ h.ids = h.ids[:0]
32+ h.index = 0
33+}
34+
35+func (h historyNavDisplay) active() bool {
36+ return len(h.ids) > 0 && h.index >= 0 && h.index < len(h.ids)
37+}
38+
39+func (n recentSessionNavigator) activeForDisplay() bool {
40+ return len(n.ids) > 1 && n.index >= 0 && n.index < len(n.ids)
41+}
42+
1343 func recentSessionTrail(cur *session, state barState) []domain.SessionID {
1444 if cur == nil {
1545 return nil
@@ -49,13 +79,51 @@ func (d *Daemon) navigateRecentSession(sess *session, ac *attachedClient, delta
4979 targetID := ac.recentNav.ids[next]
5080 targetSess := d.sessionByID(targetID)
5181 if targetSess == nil {
82+ d.clearHistoryNav(ac)
5283 d.paint(sess, ac, true)
5384 return
5485 }
5586 d.switchToTarget(sess, ac, picker.Target{Session: targetID, TabIndex: -1})
5687 if ac.currentSession() == targetSess {
5788 ac.recentNav.index = next
89+ d.showHistoryNav(ac)
90+ d.paint(targetSess, ac, true)
91+ }
92+}
93+
94+func (d *Daemon) showHistoryNav(ac *attachedClient) {
95+ if d == nil || ac == nil || !ac.recentNav.activeForDisplay() {
96+ return
97+ }
98+ ac.historyNavMu.Lock()
99+ ac.historyNav.set(ac.recentNav.ids, ac.recentNav.index, ac.historyNav.gen+1)
100+ gen := ac.historyNav.gen
101+ ac.historyNavTimer.stop()
102+ ac.historyNavTimer.retain(d.clock, historyNavDisplayTimeout, func(ports.Timer) {
103+ ac.historyNavMu.Lock()
104+ if ac.historyNav.gen != gen {
105+ ac.historyNavMu.Unlock()
106+ return
107+ }
108+ ac.historyNav.clear()
109+ ac.historyNavMu.Unlock()
110+ if sess := ac.currentSession(); sess != nil {
111+ sess.mu.Lock()
112+ stillCurrent := sess.client == ac
113+ sess.mu.Unlock()
114+ if stillCurrent {
115+ d.paint(sess, ac, true)
116+ }
117+ }
118+ })
119+ ac.historyNavMu.Unlock()
120+}
121+
122+func (d *Daemon) clearHistoryNav(ac *attachedClient) {
123+ if ac == nil {
124+ return
58125 }
126+ ac.clearHistoryNav()
59127 }
60128
61129 func (d *Daemon) pruneRecentNav(n *recentSessionNavigator) bool {
@@ -1,15 +1,45 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "time"
5+
4 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
5 "github.com/bnema/vev/internal/usecase/picker"8 "github.com/bnema/vev/internal/usecase/picker"
6 )9 )
7 10
11+const historyNavDisplayTimeout = 1500 * time.Millisecond
12+
8 type recentSessionNavigator struct {13 type recentSessionNavigator struct {
9 ids []domain.SessionID14 ids []domain.SessionID
10 index int15 index int
11 }16 }
12 17
18+type historyNavDisplay struct {
19+ ids []domain.SessionID
20+ index int
21+ gen uint64
22+}
23+
24+func (h *historyNavDisplay) set(ids []domain.SessionID, index int, gen uint64) {
25+ h.ids = append(h.ids[:0], ids...)
26+ h.index = index
27+ h.gen = gen
28+}
29+
30+func (h *historyNavDisplay) clear() {
31+ h.ids = h.ids[:0]
32+ h.index = 0
33+}
34+
35+func (h historyNavDisplay) active() bool {
36+ return len(h.ids) > 0 && h.index >= 0 && h.index < len(h.ids)
37+}
38+
39+func (n recentSessionNavigator) activeForDisplay() bool {
40+ return len(n.ids) > 1 && n.index >= 0 && n.index < len(n.ids)
41+}
42+
13 func recentSessionTrail(cur *session, state barState) []domain.SessionID {43 func recentSessionTrail(cur *session, state barState) []domain.SessionID {
14 if cur == nil {44 if cur == nil {
15 return nil45 return nil
@@ -49,13 +79,51 @@ func (d *Daemon) navigateRecentSession(sess *session, ac *attachedClient, delta
49 targetID := ac.recentNav.ids[next]79 targetID := ac.recentNav.ids[next]
50 targetSess := d.sessionByID(targetID)80 targetSess := d.sessionByID(targetID)
51 if targetSess == nil {81 if targetSess == nil {
82+ d.clearHistoryNav(ac)
52 d.paint(sess, ac, true)83 d.paint(sess, ac, true)
53 return84 return
54 }85 }
55 d.switchToTarget(sess, ac, picker.Target{Session: targetID, TabIndex: -1})86 d.switchToTarget(sess, ac, picker.Target{Session: targetID, TabIndex: -1})
56 if ac.currentSession() == targetSess {87 if ac.currentSession() == targetSess {
57 ac.recentNav.index = next88 ac.recentNav.index = next
89+ d.showHistoryNav(ac)
90+ d.paint(targetSess, ac, true)
91+ }
92+}
93+
94+func (d *Daemon) showHistoryNav(ac *attachedClient) {
95+ if d == nil || ac == nil || !ac.recentNav.activeForDisplay() {
96+ return
97+ }
98+ ac.historyNavMu.Lock()
99+ ac.historyNav.set(ac.recentNav.ids, ac.recentNav.index, ac.historyNav.gen+1)
100+ gen := ac.historyNav.gen
101+ ac.historyNavTimer.stop()
102+ ac.historyNavTimer.retain(d.clock, historyNavDisplayTimeout, func(ports.Timer) {
103+ ac.historyNavMu.Lock()
104+ if ac.historyNav.gen != gen {
105+ ac.historyNavMu.Unlock()
106+ return
107+ }
108+ ac.historyNav.clear()
109+ ac.historyNavMu.Unlock()
110+ if sess := ac.currentSession(); sess != nil {
111+ sess.mu.Lock()
112+ stillCurrent := sess.client == ac
113+ sess.mu.Unlock()
114+ if stillCurrent {
115+ d.paint(sess, ac, true)
116+ }
117+ }
118+ })
119+ ac.historyNavMu.Unlock()
120+}
121+
122+func (d *Daemon) clearHistoryNav(ac *attachedClient) {
123+ if ac == nil {
124+ return
58 }125 }
126+ ac.clearHistoryNav()
59 }127 }
60 128
61 func (d *Daemon) pruneRecentNav(n *recentSessionNavigator) bool {129 func (d *Daemon) pruneRecentNav(n *recentSessionNavigator) bool {
M internal/usecase/daemon/render.go +1 -1
@@ -293,7 +293,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
293293 preview = snapshotPickerPreview(overlays.previewTab)
294294 }
295295 repaintAttachedClients = sess.ackAttention(tb)
296- bars := d.barStateFor(sess, overlays.copyFeedback)
296+ bars := d.barStateForClient(sess, ac, overlays.copyFeedback)
297297 bars.theme = ac.getTheme()
298298
299299 styles := newThemeStyles(ac.getTheme())
@@ -293,7 +293,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
293 preview = snapshotPickerPreview(overlays.previewTab)293 preview = snapshotPickerPreview(overlays.previewTab)
294 }294 }
295 repaintAttachedClients = sess.ackAttention(tb)295 repaintAttachedClients = sess.ackAttention(tb)
296- bars := d.barStateFor(sess, overlays.copyFeedback)296+ bars := d.barStateForClient(sess, ac, overlays.copyFeedback)
297 bars.theme = ac.getTheme()297 bars.theme = ac.getTheme()
298 298
299 styles := newThemeStyles(ac.getTheme())299 styles := newThemeStyles(ac.getTheme())
M internal/usecase/daemon/render_test.go +65 -0
@@ -1027,3 +1027,68 @@ func TestCursorTailUsesExpandedStackContentPlacement(t *testing.T) {
10271027 func cursorCSI(row, col int) string {
10281028 return "\x1b[" + strconv.Itoa(row) + ";" + strconv.Itoa(col) + "H"
10291029 }
1030+
1031+func TestHistoryNavBottomBar(t *testing.T) {
1032+ styles := newThemeStyles(themeui.Theme{})
1033+
1034+ t.Run("frozen order active styling and right text", func(t *testing.T) {
1035+ row := make([]renderer.Cell, 40)
1036+ state := barState{
1037+ bottomRight: "script",
1038+ history: []historyNavSession{
1039+ {id: "work", name: "work"},
1040+ {id: "api", name: "api", active: true},
1041+ {id: "docs", name: "docs", ephemeral: true},
1042+ },
1043+ }
1044+
1045+ drawStatusBarState(row, state, styles)
1046+
1047+ got := cellsString(row)
1048+ require.Contains(t, got, " work api docs* ")
1049+ require.Contains(t, got, " script")
1050+ apiCol := strings.Index(got, "api")
1051+ require.NotEqual(t, -1, apiCol)
1052+ require.True(t, row[apiCol].Style.Equal(styles.accent))
1053+ workCol := strings.Index(got, "work")
1054+ require.NotEqual(t, -1, workCol)
1055+ require.True(t, row[workCol].Style.Equal(styles.statusBar))
1056+ })
1057+
1058+ t.Run("empty history falls back to normal mru", func(t *testing.T) {
1059+ row := make([]renderer.Cell, 30)
1060+ state := barState{
1061+ status: statusSnapshot{session: "work"},
1062+ mru: []mruSession{{name: "api"}, {name: "docs"}},
1063+ }
1064+
1065+ drawStatusBarState(row, state, styles)
1066+
1067+ got := cellsString(row)
1068+ require.Contains(t, got, " work api docs ")
1069+ workCol := strings.Index(got, "work")
1070+ require.NotEqual(t, -1, workCol)
1071+ require.True(t, row[workCol].Style.Equal(styles.accent))
1072+ })
1073+
1074+ t.Run("narrow fitting keeps active visible in contiguous window", func(t *testing.T) {
1075+ row := make([]renderer.Cell, 13)
1076+ state := barState{history: []historyNavSession{
1077+ {name: "work"},
1078+ {name: "api"},
1079+ {name: "docs", active: true},
1080+ {name: "infra"},
1081+ {name: "ops"},
1082+ }}
1083+
1084+ drawStatusBarState(row, state, styles)
1085+
1086+ got := cellsString(row)
1087+ require.Contains(t, got, "docs")
1088+ require.NotContains(t, got, "work")
1089+ require.NotContains(t, got, "ops")
1090+ docsCol := strings.Index(got, "docs")
1091+ require.NotEqual(t, -1, docsCol)
1092+ require.True(t, row[docsCol].Style.Equal(styles.accent))
1093+ })
1094+}
@@ -1027,3 +1027,68 @@ func TestCursorTailUsesExpandedStackContentPlacement(t *testing.T) {
1027 func cursorCSI(row, col int) string {1027 func cursorCSI(row, col int) string {
1028 return "\x1b[" + strconv.Itoa(row) + ";" + strconv.Itoa(col) + "H"1028 return "\x1b[" + strconv.Itoa(row) + ";" + strconv.Itoa(col) + "H"
1029 }1029 }
1030+
1031+func TestHistoryNavBottomBar(t *testing.T) {
1032+ styles := newThemeStyles(themeui.Theme{})
1033+
1034+ t.Run("frozen order active styling and right text", func(t *testing.T) {
1035+ row := make([]renderer.Cell, 40)
1036+ state := barState{
1037+ bottomRight: "script",
1038+ history: []historyNavSession{
1039+ {id: "work", name: "work"},
1040+ {id: "api", name: "api", active: true},
1041+ {id: "docs", name: "docs", ephemeral: true},
1042+ },
1043+ }
1044+
1045+ drawStatusBarState(row, state, styles)
1046+
1047+ got := cellsString(row)
1048+ require.Contains(t, got, " work api docs* ")
1049+ require.Contains(t, got, " script")
1050+ apiCol := strings.Index(got, "api")
1051+ require.NotEqual(t, -1, apiCol)
1052+ require.True(t, row[apiCol].Style.Equal(styles.accent))
1053+ workCol := strings.Index(got, "work")
1054+ require.NotEqual(t, -1, workCol)
1055+ require.True(t, row[workCol].Style.Equal(styles.statusBar))
1056+ })
1057+
1058+ t.Run("empty history falls back to normal mru", func(t *testing.T) {
1059+ row := make([]renderer.Cell, 30)
1060+ state := barState{
1061+ status: statusSnapshot{session: "work"},
1062+ mru: []mruSession{{name: "api"}, {name: "docs"}},
1063+ }
1064+
1065+ drawStatusBarState(row, state, styles)
1066+
1067+ got := cellsString(row)
1068+ require.Contains(t, got, " work api docs ")
1069+ workCol := strings.Index(got, "work")
1070+ require.NotEqual(t, -1, workCol)
1071+ require.True(t, row[workCol].Style.Equal(styles.accent))
1072+ })
1073+
1074+ t.Run("narrow fitting keeps active visible in contiguous window", func(t *testing.T) {
1075+ row := make([]renderer.Cell, 13)
1076+ state := barState{history: []historyNavSession{
1077+ {name: "work"},
1078+ {name: "api"},
1079+ {name: "docs", active: true},
1080+ {name: "infra"},
1081+ {name: "ops"},
1082+ }}
1083+
1084+ drawStatusBarState(row, state, styles)
1085+
1086+ got := cellsString(row)
1087+ require.Contains(t, got, "docs")
1088+ require.NotContains(t, got, "work")
1089+ require.NotContains(t, got, "ops")
1090+ docsCol := strings.Index(got, "docs")
1091+ require.NotEqual(t, -1, docsCol)
1092+ require.True(t, row[docsCol].Style.Equal(styles.accent))
1093+ })
1094+}
M internal/usecase/daemon/status.go +155 -14
@@ -19,9 +19,11 @@
1919 //
2020 // Locking: a pane's screen/scrollback and per-client renderer shadow are
2121 // 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.
2527 package daemon
2628
2729 import (
@@ -79,26 +81,45 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t
7981 func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) {
8082 clearStatusRow(row)
8183 x := 0
84+ rightText := composeBottomRightText(state.bottomRight, state.copyFeedback)
85+ if len(state.history) > 0 {
86+ drawHistoryNavSessions(row, &x, state.history, rightText, state.attentionFrame, styles)
87+ drawRightPlainText(row, rightText, x, styles.statusBar)
88+ return
89+ }
90+
8291 writeStatusText(row, &x, " "+state.status.session+" ", styles.accent)
8392
84- rightText := composeBottomRightText(state.bottomRight, state.copyFeedback)
8593 fittedMRU := fitMRU(state.mru, len(row), x, rightText)
8694 for i, sess := range fittedMRU {
8795 style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU))
88- name := sess.name
89- if sess.ephemeral {
90- name += "*"
91- }
92- writeStatusText(row, &x, " "+name, style)
93- if sess.attention {
94- writeStatusText(row, &x, " ", style)
95- writeBell(row, &x, state.attentionFrame)
96- }
97- writeStatusText(row, &x, " ", style)
96+ drawStatusSessionEntry(row, &x, sess.name, sess.ephemeral, sess.attention, style, state.attentionFrame)
9897 }
9998 drawRightPlainText(row, rightText, x, styles.statusBar)
10099 }
101100
101+func drawHistoryNavSessions(row []renderer.Cell, x *int, entries []historyNavSession, rightText string, attentionFrame int, styles themeStyles) {
102+ for _, sess := range fitHistoryNav(entries, len(row), *x, rightText) {
103+ style := styles.statusBar
104+ if sess.active {
105+ style = styles.accent
106+ }
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 {
117+ writeStatusText(row, x, " ", style)
118+ writeBell(row, x, attentionFrame)
119+ }
120+ writeStatusText(row, x, " ", style)
121+}
122+
102123 func composeBottomRightText(scriptText, copyFeedback string) string {
103124 if scriptText == "" {
104125 return copyFeedback
@@ -138,6 +159,7 @@ type barState struct {
138159 bottomRight string
139160 copyFeedback string
140161 mru []mruSession
162+ history []historyNavSession
141163 attentionFrame int
142164 // theme is the client's terminal theme, if reported. Its zero value
143165 // (Theme{}, Known: false) is a valid "no theme" default that resolves to
@@ -154,6 +176,14 @@ type mruSession struct {
154176 mruAt uint64
155177 }
156178
179+type historyNavSession struct {
180+ id domain.SessionID
181+ name string
182+ ephemeral bool
183+ attention bool
184+ active bool
185+}
186+
157187 type statusSnapshot struct {
158188 session string
159189 tabs []statusTab
@@ -180,6 +210,53 @@ func (s *session) statusSegments() statusSnapshot {
180210 return snap
181211 }
182212
213+func (d *Daemon) barStateForClient(cur *session, ac *attachedClient, copyFeedback string) barState {
214+ state := d.barStateFor(cur, copyFeedback)
215+ state.history = d.historyNavBarState(ac)
216+ return state
217+}
218+
219+func (d *Daemon) historyNavBarState(ac *attachedClient) []historyNavSession {
220+ if d == nil || ac == nil {
221+ return nil
222+ }
223+ ac.historyNavMu.Lock()
224+ if !ac.historyNav.active() {
225+ ac.historyNavMu.Unlock()
226+ return nil
227+ }
228+ ids := append([]domain.SessionID(nil), ac.historyNav.ids...)
229+ activeIndex := ac.historyNav.index
230+ ac.historyNavMu.Unlock()
231+
232+ d.mu.Lock()
233+ out := make([]historyNavSession, 0, len(ids))
234+ valid := true
235+ for i, id := range ids {
236+ sess := d.sessions[id]
237+ if sess == nil {
238+ valid = false
239+ break
240+ }
241+ sess.mu.Lock()
242+ entry := historyNavSession{id: sess.id, name: sess.name, ephemeral: sess.ephemeral, active: i == activeIndex}
243+ for _, tb := range sess.tabs {
244+ if tb.attention {
245+ entry.attention = true
246+ break
247+ }
248+ }
249+ sess.mu.Unlock()
250+ out = append(out, entry)
251+ }
252+ d.mu.Unlock()
253+ if !valid {
254+ d.clearHistoryNav(ac)
255+ return nil
256+ }
257+ return out
258+}
259+
183260 func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
184261 state := barState{copyFeedback: copyFeedback}
185262 if d != nil {
@@ -226,6 +303,70 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
226303 return state
227304 }
228305
306+func fitHistoryNav(entries []historyNavSession, rowLen, leftUsed int, feedback string) []historyNavSession {
307+ copyReserve := 1
308+ if feedback != "" {
309+ copyReserve = statusTextWidth(feedback) + 2
310+ }
311+ budget := rowLen - leftUsed - copyReserve
312+ if budget <= 0 || len(entries) == 0 {
313+ return nil
314+ }
315+ cost := func(e historyNavSession) int {
316+ name := e.name
317+ if e.ephemeral {
318+ name += "*"
319+ }
320+ n := 2 + statusTextWidth(name)
321+ if e.attention {
322+ n += 1 + renderer.RuneWidth(attentionGlyph)
323+ }
324+ return n
325+ }
326+ used := 0
327+ for _, e := range entries {
328+ used += cost(e)
329+ }
330+ if used <= budget {
331+ return entries
332+ }
333+ active := 0
334+ for i, e := range entries {
335+ if e.active {
336+ active = i
337+ break
338+ }
339+ }
340+ if cost(entries[active]) > budget {
341+ return nil
342+ }
343+ start, end := active, active+1
344+ used = cost(entries[active])
345+ for {
346+ expanded := false
347+ if start > 0 {
348+ c := cost(entries[start-1])
349+ if used+c <= budget {
350+ start--
351+ used += c
352+ expanded = true
353+ }
354+ }
355+ if end < len(entries) {
356+ c := cost(entries[end])
357+ if used+c <= budget {
358+ end++
359+ used += c
360+ expanded = true
361+ }
362+ }
363+ if !expanded {
364+ break
365+ }
366+ }
367+ return entries[start:end]
368+}
369+
229370 func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSession {
230371 // With no feedback, keep one blank trailing cell; with feedback, reserve
231372 // its " text" width plus a one-cell gap so drawRightPlainText always fits.
@@ -19,9 +19,11 @@
19 //19 //
20 // Locking: a pane's screen/scrollback and per-client renderer shadow are20 // 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 by21 // 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 order22+// 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 daemon27 package daemon
26 28
27 import (29 import (
@@ -79,26 +81,45 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t
79 func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) {81 func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) {
80 clearStatusRow(row)82 clearStatusRow(row)
81 x := 083 x := 0
84+ rightText := composeBottomRightText(state.bottomRight, state.copyFeedback)
85+ if len(state.history) > 0 {
86+ drawHistoryNavSessions(row, &x, state.history, rightText, state.attentionFrame, styles)
87+ drawRightPlainText(row, rightText, x, styles.statusBar)
88+ return
89+ }
90+
82 writeStatusText(row, &x, " "+state.status.session+" ", styles.accent)91 writeStatusText(row, &x, " "+state.status.session+" ", styles.accent)
83 92
84- rightText := composeBottomRightText(state.bottomRight, state.copyFeedback)
85 fittedMRU := fitMRU(state.mru, len(row), x, rightText)93 fittedMRU := fitMRU(state.mru, len(row), x, rightText)
86 for i, sess := range fittedMRU {94 for i, sess := range fittedMRU {
87 style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU))95 style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU))
88- name := sess.name96+ drawStatusSessionEntry(row, &x, sess.name, sess.ephemeral, sess.attention, style, state.attentionFrame)
89- if sess.ephemeral {
90- name += "*"
91- }
92- writeStatusText(row, &x, " "+name, style)
93- if sess.attention {
94- writeStatusText(row, &x, " ", style)
95- writeBell(row, &x, state.attentionFrame)
96- }
97- writeStatusText(row, &x, " ", style)
98 }97 }
99 drawRightPlainText(row, rightText, x, styles.statusBar)98 drawRightPlainText(row, rightText, x, styles.statusBar)
100 }99 }
101 100
101+func drawHistoryNavSessions(row []renderer.Cell, x *int, entries []historyNavSession, rightText string, attentionFrame int, styles themeStyles) {
102+ for _, sess := range fitHistoryNav(entries, len(row), *x, rightText) {
103+ style := styles.statusBar
104+ if sess.active {
105+ style = styles.accent
106+ }
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 {
117+ writeStatusText(row, x, " ", style)
118+ writeBell(row, x, attentionFrame)
119+ }
120+ writeStatusText(row, x, " ", style)
121+}
122+
102 func composeBottomRightText(scriptText, copyFeedback string) string {123 func composeBottomRightText(scriptText, copyFeedback string) string {
103 if scriptText == "" {124 if scriptText == "" {
104 return copyFeedback125 return copyFeedback
@@ -138,6 +159,7 @@ type barState struct {
138 bottomRight string159 bottomRight string
139 copyFeedback string160 copyFeedback string
140 mru []mruSession161 mru []mruSession
162+ history []historyNavSession
141 attentionFrame int163 attentionFrame int
142 // theme is the client's terminal theme, if reported. Its zero value164 // theme is the client's terminal theme, if reported. Its zero value
143 // (Theme{}, Known: false) is a valid "no theme" default that resolves to165 // (Theme{}, Known: false) is a valid "no theme" default that resolves to
@@ -154,6 +176,14 @@ type mruSession struct {
154 mruAt uint64176 mruAt uint64
155 }177 }
156 178
179+type historyNavSession struct {
180+ id domain.SessionID
181+ name string
182+ ephemeral bool
183+ attention bool
184+ active bool
185+}
186+
157 type statusSnapshot struct {187 type statusSnapshot struct {
158 session string188 session string
159 tabs []statusTab189 tabs []statusTab
@@ -180,6 +210,53 @@ func (s *session) statusSegments() statusSnapshot {
180 return snap210 return snap
181 }211 }
182 212
213+func (d *Daemon) barStateForClient(cur *session, ac *attachedClient, copyFeedback string) barState {
214+ state := d.barStateFor(cur, copyFeedback)
215+ state.history = d.historyNavBarState(ac)
216+ return state
217+}
218+
219+func (d *Daemon) historyNavBarState(ac *attachedClient) []historyNavSession {
220+ if d == nil || ac == nil {
221+ return nil
222+ }
223+ ac.historyNavMu.Lock()
224+ if !ac.historyNav.active() {
225+ ac.historyNavMu.Unlock()
226+ return nil
227+ }
228+ ids := append([]domain.SessionID(nil), ac.historyNav.ids...)
229+ activeIndex := ac.historyNav.index
230+ ac.historyNavMu.Unlock()
231+
232+ d.mu.Lock()
233+ out := make([]historyNavSession, 0, len(ids))
234+ valid := true
235+ for i, id := range ids {
236+ sess := d.sessions[id]
237+ if sess == nil {
238+ valid = false
239+ break
240+ }
241+ sess.mu.Lock()
242+ entry := historyNavSession{id: sess.id, name: sess.name, ephemeral: sess.ephemeral, active: i == activeIndex}
243+ for _, tb := range sess.tabs {
244+ if tb.attention {
245+ entry.attention = true
246+ break
247+ }
248+ }
249+ sess.mu.Unlock()
250+ out = append(out, entry)
251+ }
252+ d.mu.Unlock()
253+ if !valid {
254+ d.clearHistoryNav(ac)
255+ return nil
256+ }
257+ return out
258+}
259+
183 func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {260 func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
184 state := barState{copyFeedback: copyFeedback}261 state := barState{copyFeedback: copyFeedback}
185 if d != nil {262 if d != nil {
@@ -226,6 +303,70 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
226 return state303 return state
227 }304 }
228 305
306+func fitHistoryNav(entries []historyNavSession, rowLen, leftUsed int, feedback string) []historyNavSession {
307+ copyReserve := 1
308+ if feedback != "" {
309+ copyReserve = statusTextWidth(feedback) + 2
310+ }
311+ budget := rowLen - leftUsed - copyReserve
312+ if budget <= 0 || len(entries) == 0 {
313+ return nil
314+ }
315+ cost := func(e historyNavSession) int {
316+ name := e.name
317+ if e.ephemeral {
318+ name += "*"
319+ }
320+ n := 2 + statusTextWidth(name)
321+ if e.attention {
322+ n += 1 + renderer.RuneWidth(attentionGlyph)
323+ }
324+ return n
325+ }
326+ used := 0
327+ for _, e := range entries {
328+ used += cost(e)
329+ }
330+ if used <= budget {
331+ return entries
332+ }
333+ active := 0
334+ for i, e := range entries {
335+ if e.active {
336+ active = i
337+ break
338+ }
339+ }
340+ if cost(entries[active]) > budget {
341+ return nil
342+ }
343+ start, end := active, active+1
344+ used = cost(entries[active])
345+ for {
346+ expanded := false
347+ if start > 0 {
348+ c := cost(entries[start-1])
349+ if used+c <= budget {
350+ start--
351+ used += c
352+ expanded = true
353+ }
354+ }
355+ if end < len(entries) {
356+ c := cost(entries[end])
357+ if used+c <= budget {
358+ end++
359+ used += c
360+ expanded = true
361+ }
362+ }
363+ if !expanded {
364+ break
365+ }
366+ }
367+ return entries[start:end]
368+}
369+
229 func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSession {370 func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSession {
230 // With no feedback, keep one blank trailing cell; with feedback, reserve371 // With no feedback, keep one blank trailing cell; with feedback, reserve
231 // its " text" width plus a one-cell gap so drawRightPlainText always fits.372 // its " text" width plus a one-cell gap so drawRightPlainText always fits.

Keyboard shortcuts

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

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

Branches 2

Tags 13

↑↓ moveenter openesc close