detached a6ffc40

feat(daemon): stabilize FSK BSK history navigation

commit
a6ffc40376953dad65fc0bc3bf5ed2028b1cae96
author
brice · · 2026-07-06 17:19 UTC
parent
7447d40
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +543 -26
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 +41 -22
@@ -39,27 +39,30 @@ import (
3939 )
4040
4141 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
61- linkMu sync.Mutex
62- sendMu sync.Mutex
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 sync.Mutex
62+ historyNav historyNavDisplay
63+ historyNavTimer pendingByteTimer
64+ linkMu sync.Mutex
65+ sendMu sync.Mutex
6366 }
6467
6568 type cursorOut struct {
@@ -108,7 +111,23 @@ func (ac *attachedClient) initOverlays() {
108111
109112 func (ac *attachedClient) currentSession() *session { return ac.sess.Get() }
110113
111-func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }
114+func (ac *attachedClient) setSession(sess *session) {
115+ if sess == nil {
116+ ac.clearHistoryNav()
117+ }
118+ ac.sess.Set(sess)
119+}
120+
121+func (ac *attachedClient) clearHistoryNav() {
122+ if ac == nil {
123+ return
124+ }
125+ ac.historyNavMu.Lock()
126+ ac.historyNav.gen++
127+ ac.historyNavTimer.stop()
128+ ac.historyNav.clear()
129+ ac.historyNavMu.Unlock()
130+}
112131
113132 func (ac *attachedClient) getTheme() themeui.Theme {
114133 ac.themeMu.Lock()
@@ -39,27 +39,30 @@ import (
39 )39 )
40 40
41 type attachedClient struct {41 type attachedClient struct {
42- tr ports.Transport42+ tr ports.Transport
43- rend *renderer.Renderer43+ rend *renderer.Renderer
44- overlays *overlayRuntime44+ overlays *overlayRuntime
45- clientID [16]byte45+ clientID [16]byte
46- resumeCapable bool46+ resumeCapable bool
47- resumeToken uint6447+ resumeToken uint64
48- parked bool48+ parked bool
49- nextStateNum uint6449+ nextStateNum uint64
50- echoAck atomic.Uint6450+ echoAck atomic.Uint64
51- bars barCache // only touched while sendMu is held51+ bars barCache // only touched while sendMu is held
52- size domain.Size52+ size domain.Size
53- keys *keys.Router53+ keys *keys.Router
54- sess Guarded[*session]54+ sess Guarded[*session]
55- mouseScan mouse.Scanner55+ mouseScan mouse.Scanner
56- themeMu sync.Mutex56+ themeMu sync.Mutex
57- theme themeui.Theme57+ theme themeui.Theme
58- clientTheme themeui.Theme58+ clientTheme themeui.Theme
59- lastCursor cursorOut59+ lastCursor cursorOut
60- recentNav recentSessionNavigator60+ recentNav recentSessionNavigator
61- linkMu sync.Mutex61+ historyNavMu sync.Mutex
62- sendMu sync.Mutex62+ historyNav historyNavDisplay
63+ historyNavTimer pendingByteTimer
64+ linkMu sync.Mutex
65+ sendMu sync.Mutex
63 }66 }
64 67
65 type cursorOut struct {68 type cursorOut struct {
@@ -108,7 +111,23 @@ func (ac *attachedClient) initOverlays() {
108 111
109 func (ac *attachedClient) currentSession() *session { return ac.sess.Get() }112 func (ac *attachedClient) currentSession() *session { return ac.sess.Get() }
110 113
111-func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }114+func (ac *attachedClient) setSession(sess *session) {
115+ if sess == nil {
116+ ac.clearHistoryNav()
117+ }
118+ ac.sess.Set(sess)
119+}
120+
121+func (ac *attachedClient) clearHistoryNav() {
122+ if ac == nil {
123+ return
124+ }
125+ ac.historyNavMu.Lock()
126+ ac.historyNav.gen++
127+ ac.historyNavTimer.stop()
128+ ac.historyNav.clear()
129+ ac.historyNavMu.Unlock()
130+}
112 131
113 func (ac *attachedClient) getTheme() themeui.Theme {132 func (ac *attachedClient) getTheme() themeui.Theme {
114 ac.themeMu.Lock()133 ac.themeMu.Lock()
M internal/usecase/daemon/input_test.go +210 -0
@@ -287,6 +287,216 @@ 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 TestHistoryNavActivation(t *testing.T) {
314+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
315+ defer releaseAll(releases)
316+ recent := d.sessions[domain.SessionID("recent")]
317+ older := d.sessions[domain.SessionID("older")]
318+
319+ runPaletteCommand(t, d, current, ac, "BSK")
320+ require.Same(t, recent, ac.currentSession())
321+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
322+ require.GreaterOrEqual(t, drainOutputFrames(sends), 2)
323+
324+ runPaletteCommand(t, d, recent, ac, "BSK")
325+ require.Same(t, older, ac.currentSession())
326+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 2)
327+
328+ runPaletteCommand(t, d, older, ac, "FSK")
329+ require.Same(t, recent, ac.currentSession())
330+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
331+}
332+
333+func TestHistoryNavBoundaryDoesNotActivate(t *testing.T) {
334+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
335+ defer releaseAll(releases)
336+
337+ runPaletteCommand(t, d, current, ac, "FSK")
338+
339+ require.Same(t, current, ac.currentSession())
340+ ac.historyNavMu.Lock()
341+ active := ac.historyNav.active()
342+ ac.historyNavMu.Unlock()
343+ require.False(t, active)
344+}
345+
346+func TestHistoryNavTimeoutExpires(t *testing.T) {
347+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
348+ defer releaseAll(releases)
349+ clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 1)}
350+ d.clock = clock
351+
352+ runPaletteCommand(t, d, current, ac, "BSK")
353+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
354+ timer := <-clock.timers
355+ drainOutputFrames(sends)
356+ timer.fire()
357+
358+ require.Eventually(t, func() bool {
359+ ac.historyNavMu.Lock()
360+ defer ac.historyNavMu.Unlock()
361+ return !ac.historyNav.active()
362+ }, time.Second, time.Millisecond)
363+ require.Eventually(t, func() bool { return drainOutputFrames(sends) > 0 }, time.Second, time.Millisecond)
364+}
365+
366+func TestHistoryNavStaleTimerCannotClearFreshActivation(t *testing.T) {
367+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
368+ defer releaseAll(releases)
369+ clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 2)}
370+ d.clock = clock
371+
372+ runPaletteCommand(t, d, current, ac, "BSK")
373+ staleTimer := <-clock.timers
374+ ac.clearHistoryNav()
375+ runPaletteCommand(t, d, ac.currentSession(), ac, "FSK")
376+ <-clock.timers
377+
378+ staleTimer.fire()
379+
380+ require.Eventually(t, func() bool {
381+ ac.historyNavMu.Lock()
382+ defer ac.historyNavMu.Unlock()
383+ return ac.historyNav.active() && ac.historyNav.index == 0 && ac.historyNav.gen > 1
384+ }, time.Second, time.Millisecond)
385+}
386+
387+func TestHistoryNavTimerStoppedOnDetachAndReplacement(t *testing.T) {
388+ t.Run("detach stops stale timer repaint", func(t *testing.T) {
389+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
390+ defer releaseAll(releases)
391+ clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 1)}
392+ d.clock = clock
393+
394+ runPaletteCommand(t, d, current, ac, "BSK")
395+ timer := <-clock.timers
396+ recent := ac.currentSession()
397+ d.clientGone(recent, ac, ac.transport(), true)
398+ drainOutputFrames(sends)
399+ timer.fire()
400+
401+ requireNoHistoryNav(t, ac)
402+ require.Never(t, func() bool { return drainOutputFrames(sends) > 0 }, 25*time.Millisecond, time.Millisecond)
403+ })
404+
405+ t.Run("replacement stops stale timer repaint", func(t *testing.T) {
406+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
407+ defer releaseAll(releases)
408+ clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 1)}
409+ d.clock = clock
410+
411+ runPaletteCommand(t, d, current, ac, "BSK")
412+ timer := <-clock.timers
413+ d.detachReplacedClient(ac)
414+ drainOutputFrames(sends)
415+ timer.fire()
416+
417+ requireNoHistoryNav(t, ac)
418+ require.Never(t, func() bool { return drainOutputFrames(sends) > 0 }, 25*time.Millisecond, time.Millisecond)
419+ })
420+}
421+
422+func TestHistoryNavClearsOnInvalidTrailAndNonHistorySwitch(t *testing.T) {
423+ t.Run("invalid frozen trail clears", func(t *testing.T) {
424+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
425+ defer releaseAll(releases)
426+
427+ runPaletteCommand(t, d, current, ac, "BSK")
428+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
429+ d.mu.Lock()
430+ delete(d.sessions, domain.SessionID("older"))
431+ d.mu.Unlock()
432+
433+ require.Nil(t, d.historyNavBarState(ac))
434+ requireNoHistoryNav(t, ac)
435+ })
436+
437+ t.Run("non history palette command clears frozen trail", func(t *testing.T) {
438+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
439+ defer releaseAll(releases)
440+
441+ runPaletteCommand(t, d, current, ac, "BSK")
442+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
443+ runPaletteCommand(t, d, ac.currentSession(), ac, "NXT")
444+
445+ requireNoHistoryNav(t, ac)
446+ })
447+}
448+
449+func requireHistoryNav(t *testing.T, ac *attachedClient, want []domain.SessionID, wantIndex int) {
450+ t.Helper()
451+ ac.historyNavMu.Lock()
452+ defer ac.historyNavMu.Unlock()
453+ require.True(t, ac.historyNav.active())
454+ require.Equal(t, want, ac.historyNav.ids)
455+ require.Equal(t, wantIndex, ac.historyNav.index)
456+}
457+
458+func requireNoHistoryNav(t *testing.T, ac *attachedClient) {
459+ t.Helper()
460+ ac.historyNavMu.Lock()
461+ defer ac.historyNavMu.Unlock()
462+ require.False(t, ac.historyNav.active())
463+}
464+
465+func drainOutputFrames(sends chan ports.Frame) int {
466+ count := 0
467+ for {
468+ select {
469+ case f := <-sends:
470+ if f.Type == ports.MsgOutput {
471+ count++
472+ }
473+ default:
474+ return count
475+ }
476+ }
477+}
478+
479+type historyNavTestClock struct {
480+ timers chan *historyNavTestTimer
481+}
482+
483+func (c *historyNavTestClock) Now() time.Time { return time.Time{} }
484+
485+func (c *historyNavTestClock) NewTimer(time.Duration) ports.Timer {
486+ t := &historyNavTestTimer{ch: make(chan time.Time, 1)}
487+ c.timers <- t
488+ return t
489+}
490+
491+type historyNavTestTimer struct {
492+ ch chan time.Time
493+}
494+
495+func (t *historyNavTestTimer) C() <-chan time.Time { return t.ch }
496+func (t *historyNavTestTimer) Reset(time.Duration) bool { return false }
497+func (t *historyNavTestTimer) Stop() bool { return true }
498+func (t *historyNavTestTimer) fire() { t.ch <- time.Time{} }
499+
290500 func TestPaletteBackForwardSessionStaleTrail(t *testing.T) {
291501 t.Run("forward skips a killed intermediate session", func(t *testing.T) {
292502 d, current, ac, _, releases := newRecentNavigationTestSessions(t)
@@ -287,6 +287,216 @@ 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 TestHistoryNavActivation(t *testing.T) {
314+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
315+ defer releaseAll(releases)
316+ recent := d.sessions[domain.SessionID("recent")]
317+ older := d.sessions[domain.SessionID("older")]
318+
319+ runPaletteCommand(t, d, current, ac, "BSK")
320+ require.Same(t, recent, ac.currentSession())
321+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
322+ require.GreaterOrEqual(t, drainOutputFrames(sends), 2)
323+
324+ runPaletteCommand(t, d, recent, ac, "BSK")
325+ require.Same(t, older, ac.currentSession())
326+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 2)
327+
328+ runPaletteCommand(t, d, older, ac, "FSK")
329+ require.Same(t, recent, ac.currentSession())
330+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
331+}
332+
333+func TestHistoryNavBoundaryDoesNotActivate(t *testing.T) {
334+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
335+ defer releaseAll(releases)
336+
337+ runPaletteCommand(t, d, current, ac, "FSK")
338+
339+ require.Same(t, current, ac.currentSession())
340+ ac.historyNavMu.Lock()
341+ active := ac.historyNav.active()
342+ ac.historyNavMu.Unlock()
343+ require.False(t, active)
344+}
345+
346+func TestHistoryNavTimeoutExpires(t *testing.T) {
347+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
348+ defer releaseAll(releases)
349+ clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 1)}
350+ d.clock = clock
351+
352+ runPaletteCommand(t, d, current, ac, "BSK")
353+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
354+ timer := <-clock.timers
355+ drainOutputFrames(sends)
356+ timer.fire()
357+
358+ require.Eventually(t, func() bool {
359+ ac.historyNavMu.Lock()
360+ defer ac.historyNavMu.Unlock()
361+ return !ac.historyNav.active()
362+ }, time.Second, time.Millisecond)
363+ require.Eventually(t, func() bool { return drainOutputFrames(sends) > 0 }, time.Second, time.Millisecond)
364+}
365+
366+func TestHistoryNavStaleTimerCannotClearFreshActivation(t *testing.T) {
367+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
368+ defer releaseAll(releases)
369+ clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 2)}
370+ d.clock = clock
371+
372+ runPaletteCommand(t, d, current, ac, "BSK")
373+ staleTimer := <-clock.timers
374+ ac.clearHistoryNav()
375+ runPaletteCommand(t, d, ac.currentSession(), ac, "FSK")
376+ <-clock.timers
377+
378+ staleTimer.fire()
379+
380+ require.Eventually(t, func() bool {
381+ ac.historyNavMu.Lock()
382+ defer ac.historyNavMu.Unlock()
383+ return ac.historyNav.active() && ac.historyNav.index == 0 && ac.historyNav.gen > 1
384+ }, time.Second, time.Millisecond)
385+}
386+
387+func TestHistoryNavTimerStoppedOnDetachAndReplacement(t *testing.T) {
388+ t.Run("detach stops stale timer repaint", func(t *testing.T) {
389+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
390+ defer releaseAll(releases)
391+ clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 1)}
392+ d.clock = clock
393+
394+ runPaletteCommand(t, d, current, ac, "BSK")
395+ timer := <-clock.timers
396+ recent := ac.currentSession()
397+ d.clientGone(recent, ac, ac.transport(), true)
398+ drainOutputFrames(sends)
399+ timer.fire()
400+
401+ requireNoHistoryNav(t, ac)
402+ require.Never(t, func() bool { return drainOutputFrames(sends) > 0 }, 25*time.Millisecond, time.Millisecond)
403+ })
404+
405+ t.Run("replacement stops stale timer repaint", func(t *testing.T) {
406+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
407+ defer releaseAll(releases)
408+ clock := &historyNavTestClock{timers: make(chan *historyNavTestTimer, 1)}
409+ d.clock = clock
410+
411+ runPaletteCommand(t, d, current, ac, "BSK")
412+ timer := <-clock.timers
413+ d.detachReplacedClient(ac)
414+ drainOutputFrames(sends)
415+ timer.fire()
416+
417+ requireNoHistoryNav(t, ac)
418+ require.Never(t, func() bool { return drainOutputFrames(sends) > 0 }, 25*time.Millisecond, time.Millisecond)
419+ })
420+}
421+
422+func TestHistoryNavClearsOnInvalidTrailAndNonHistorySwitch(t *testing.T) {
423+ t.Run("invalid frozen trail clears", func(t *testing.T) {
424+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
425+ defer releaseAll(releases)
426+
427+ runPaletteCommand(t, d, current, ac, "BSK")
428+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
429+ d.mu.Lock()
430+ delete(d.sessions, domain.SessionID("older"))
431+ d.mu.Unlock()
432+
433+ require.Nil(t, d.historyNavBarState(ac))
434+ requireNoHistoryNav(t, ac)
435+ })
436+
437+ t.Run("non history palette command clears frozen trail", func(t *testing.T) {
438+ d, current, ac, _, releases := newRecentNavigationTestSessions(t)
439+ defer releaseAll(releases)
440+
441+ runPaletteCommand(t, d, current, ac, "BSK")
442+ requireHistoryNav(t, ac, []domain.SessionID{"current", "recent", "older"}, 1)
443+ runPaletteCommand(t, d, ac.currentSession(), ac, "NXT")
444+
445+ requireNoHistoryNav(t, ac)
446+ })
447+}
448+
449+func requireHistoryNav(t *testing.T, ac *attachedClient, want []domain.SessionID, wantIndex int) {
450+ t.Helper()
451+ ac.historyNavMu.Lock()
452+ defer ac.historyNavMu.Unlock()
453+ require.True(t, ac.historyNav.active())
454+ require.Equal(t, want, ac.historyNav.ids)
455+ require.Equal(t, wantIndex, ac.historyNav.index)
456+}
457+
458+func requireNoHistoryNav(t *testing.T, ac *attachedClient) {
459+ t.Helper()
460+ ac.historyNavMu.Lock()
461+ defer ac.historyNavMu.Unlock()
462+ require.False(t, ac.historyNav.active())
463+}
464+
465+func drainOutputFrames(sends chan ports.Frame) int {
466+ count := 0
467+ for {
468+ select {
469+ case f := <-sends:
470+ if f.Type == ports.MsgOutput {
471+ count++
472+ }
473+ default:
474+ return count
475+ }
476+ }
477+}
478+
479+type historyNavTestClock struct {
480+ timers chan *historyNavTestTimer
481+}
482+
483+func (c *historyNavTestClock) Now() time.Time { return time.Time{} }
484+
485+func (c *historyNavTestClock) NewTimer(time.Duration) ports.Timer {
486+ t := &historyNavTestTimer{ch: make(chan time.Time, 1)}
487+ c.timers <- t
488+ return t
489+}
490+
491+type historyNavTestTimer struct {
492+ ch chan time.Time
493+}
494+
495+func (t *historyNavTestTimer) C() <-chan time.Time { return t.ch }
496+func (t *historyNavTestTimer) Reset(time.Duration) bool { return false }
497+func (t *historyNavTestTimer) Stop() bool { return true }
498+func (t *historyNavTestTimer) fire() { t.ch <- time.Time{} }
499+
290 func TestPaletteBackForwardSessionStaleTrail(t *testing.T) {500 func TestPaletteBackForwardSessionStaleTrail(t *testing.T) {
291 t.Run("forward skips a killed intermediate session", func(t *testing.T) {501 t.Run("forward skips a killed intermediate session", func(t *testing.T) {
292 d, current, ac, _, releases := newRecentNavigationTestSessions(t)502 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.Code) {
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(code string) bool {
144+ return code == "BSK" || code == "FSK"
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.Code) {
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(code string) bool {
144+ return code == "BSK" || code == "FSK"
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 +6 -0
@@ -180,6 +180,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
180180 d.closePicker(ac)
181181 }
182182 if switchTarget && ok {
183+ d.clearHistoryNav(ac)
183184 d.switchToTarget(sess, ac, target)
184185 return
185186 }
@@ -350,16 +351,21 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
350351 }
351352
352353 func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) {
354+ d.clearHistoryNav(ac)
353355 if target.Stopped {
354356 d.resumeStoppedAndSwitch(sess, ac, target)
355357 return
356358 }
357359 targetSess := d.sessionByID(target.Session)
358360 if targetSess == nil {
361+ d.clearHistoryNav(ac)
359362 d.paint(sess, ac, true)
360363 return
361364 }
362365 if targetSess == sess {
366+ if target.TabIndex >= 0 {
367+ d.clearHistoryNav(ac)
368+ }
363369 sess.switchTab(target.TabIndex)
364370 d.paint(sess, ac, true)
365371 return
@@ -180,6 +180,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
180 d.closePicker(ac)180 d.closePicker(ac)
181 }181 }
182 if switchTarget && ok {182 if switchTarget && ok {
183+ d.clearHistoryNav(ac)
183 d.switchToTarget(sess, ac, target)184 d.switchToTarget(sess, ac, target)
184 return185 return
185 }186 }
@@ -350,16 +351,21 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
350 }351 }
351 352
352 func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) {353 func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) {
354+ d.clearHistoryNav(ac)
353 if target.Stopped {355 if target.Stopped {
354 d.resumeStoppedAndSwitch(sess, ac, target)356 d.resumeStoppedAndSwitch(sess, ac, target)
355 return357 return
356 }358 }
357 targetSess := d.sessionByID(target.Session)359 targetSess := d.sessionByID(target.Session)
358 if targetSess == nil {360 if targetSess == nil {
361+ d.clearHistoryNav(ac)
359 d.paint(sess, ac, true)362 d.paint(sess, ac, true)
360 return363 return
361 }364 }
362 if targetSess == sess {365 if targetSess == sess {
366+ if target.TabIndex >= 0 {
367+ d.clearHistoryNav(ac)
368+ }
363 sess.switchTab(target.TabIndex)369 sess.switchTab(target.TabIndex)
364 d.paint(sess, ac, true)370 d.paint(sess, ac, true)
365 return371 return
M internal/usecase/daemon/recent_nav.go +65 -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,15 +79,50 @@ 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)
5891 }
5992 }
6093
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+ ac.clearHistoryNav()
124+}
125+
61126 func (d *Daemon) pruneRecentNav(n *recentSessionNavigator) bool {
62127 if n == nil || len(n.ids) == 0 || n.index < 0 || n.index >= len(n.ids) {
63128 return false
@@ -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,15 +79,50 @@ 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)
58 }91 }
59 }92 }
60 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+ ac.clearHistoryNav()
124+}
125+
61 func (d *Daemon) pruneRecentNav(n *recentSessionNavigator) bool {126 func (d *Daemon) pruneRecentNav(n *recentSessionNavigator) bool {
62 if n == nil || len(n.ids) == 0 || n.index < 0 || n.index >= len(n.ids) {127 if n == nil || len(n.ids) == 0 || n.index < 0 || n.index >= len(n.ids) {
63 return false128 return false
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 +146 -1
@@ -79,9 +79,15 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t
7979 func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) {
8080 clearStatusRow(row)
8181 x := 0
82+ rightText := composeBottomRightText(state.bottomRight, state.copyFeedback)
83+ if len(state.history) > 0 {
84+ drawHistoryNavSessions(row, &x, state.history, rightText, state.attentionFrame, styles)
85+ drawRightPlainText(row, rightText, x, styles.statusBar)
86+ return
87+ }
88+
8289 writeStatusText(row, &x, " "+state.status.session+" ", styles.accent)
8390
84- rightText := composeBottomRightText(state.bottomRight, state.copyFeedback)
8591 fittedMRU := fitMRU(state.mru, len(row), x, rightText)
8692 for i, sess := range fittedMRU {
8793 style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU))
@@ -99,6 +105,25 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles)
99105 drawRightPlainText(row, rightText, x, styles.statusBar)
100106 }
101107
108+func drawHistoryNavSessions(row []renderer.Cell, x *int, entries []historyNavSession, rightText string, attentionFrame int, styles themeStyles) {
109+ for _, sess := range fitHistoryNav(entries, len(row), *x, rightText) {
110+ style := styles.statusBar
111+ if sess.active {
112+ style = styles.accent
113+ }
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+ }
123+ writeStatusText(row, x, " ", style)
124+ }
125+}
126+
102127 func composeBottomRightText(scriptText, copyFeedback string) string {
103128 if scriptText == "" {
104129 return copyFeedback
@@ -138,6 +163,7 @@ type barState struct {
138163 bottomRight string
139164 copyFeedback string
140165 mru []mruSession
166+ history []historyNavSession
141167 attentionFrame int
142168 // theme is the client's terminal theme, if reported. Its zero value
143169 // (Theme{}, Known: false) is a valid "no theme" default that resolves to
@@ -154,6 +180,14 @@ type mruSession struct {
154180 mruAt uint64
155181 }
156182
183+type historyNavSession struct {
184+ id domain.SessionID
185+ name string
186+ ephemeral bool
187+ attention bool
188+ active bool
189+}
190+
157191 type statusSnapshot struct {
158192 session string
159193 tabs []statusTab
@@ -180,6 +214,53 @@ func (s *session) statusSegments() statusSnapshot {
180214 return snap
181215 }
182216
217+func (d *Daemon) barStateForClient(cur *session, ac *attachedClient, copyFeedback string) barState {
218+ state := d.barStateFor(cur, copyFeedback)
219+ state.history = d.historyNavBarState(ac)
220+ return state
221+}
222+
223+func (d *Daemon) historyNavBarState(ac *attachedClient) []historyNavSession {
224+ if d == nil || ac == nil {
225+ return nil
226+ }
227+ ac.historyNavMu.Lock()
228+ if !ac.historyNav.active() {
229+ ac.historyNavMu.Unlock()
230+ return nil
231+ }
232+ ids := append([]domain.SessionID(nil), ac.historyNav.ids...)
233+ activeIndex := ac.historyNav.index
234+ ac.historyNavMu.Unlock()
235+
236+ d.mu.Lock()
237+ out := make([]historyNavSession, 0, len(ids))
238+ valid := true
239+ for i, id := range ids {
240+ sess := d.sessions[id]
241+ if sess == nil {
242+ valid = false
243+ break
244+ }
245+ sess.mu.Lock()
246+ entry := historyNavSession{id: sess.id, name: sess.name, ephemeral: sess.ephemeral, active: i == activeIndex}
247+ for _, tb := range sess.tabs {
248+ if tb.attention {
249+ entry.attention = true
250+ break
251+ }
252+ }
253+ sess.mu.Unlock()
254+ out = append(out, entry)
255+ }
256+ d.mu.Unlock()
257+ if !valid {
258+ d.clearHistoryNav(ac)
259+ return nil
260+ }
261+ return out
262+}
263+
183264 func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
184265 state := barState{copyFeedback: copyFeedback}
185266 if d != nil {
@@ -226,6 +307,70 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
226307 return state
227308 }
228309
310+func fitHistoryNav(entries []historyNavSession, rowLen, leftUsed int, feedback string) []historyNavSession {
311+ copyReserve := 1
312+ if feedback != "" {
313+ copyReserve = statusTextWidth(feedback) + 2
314+ }
315+ budget := rowLen - leftUsed - copyReserve
316+ if budget <= 0 || len(entries) == 0 {
317+ return nil
318+ }
319+ cost := func(e historyNavSession) int {
320+ name := e.name
321+ if e.ephemeral {
322+ name += "*"
323+ }
324+ n := 2 + statusTextWidth(name)
325+ if e.attention {
326+ n += 1 + renderer.RuneWidth(attentionGlyph)
327+ }
328+ return n
329+ }
330+ used := 0
331+ for _, e := range entries {
332+ used += cost(e)
333+ }
334+ if used <= budget {
335+ return entries
336+ }
337+ active := 0
338+ for i, e := range entries {
339+ if e.active {
340+ active = i
341+ break
342+ }
343+ }
344+ if cost(entries[active]) > budget {
345+ return nil
346+ }
347+ start, end := active, active+1
348+ used = cost(entries[active])
349+ for {
350+ expanded := false
351+ if start > 0 {
352+ c := cost(entries[start-1])
353+ if used+c <= budget {
354+ start--
355+ used += c
356+ expanded = true
357+ }
358+ }
359+ if end < len(entries) {
360+ c := cost(entries[end])
361+ if used+c <= budget {
362+ end++
363+ used += c
364+ expanded = true
365+ }
366+ }
367+ if !expanded {
368+ break
369+ }
370+ }
371+ return entries[start:end]
372+}
373+
229374 func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSession {
230375 // With no feedback, keep one blank trailing cell; with feedback, reserve
231376 // its " text" width plus a one-cell gap so drawRightPlainText always fits.
@@ -79,9 +79,15 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t
79 func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) {79 func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) {
80 clearStatusRow(row)80 clearStatusRow(row)
81 x := 081 x := 0
82+ rightText := composeBottomRightText(state.bottomRight, state.copyFeedback)
83+ if len(state.history) > 0 {
84+ drawHistoryNavSessions(row, &x, state.history, rightText, state.attentionFrame, styles)
85+ drawRightPlainText(row, rightText, x, styles.statusBar)
86+ return
87+ }
88+
82 writeStatusText(row, &x, " "+state.status.session+" ", styles.accent)89 writeStatusText(row, &x, " "+state.status.session+" ", styles.accent)
83 90
84- rightText := composeBottomRightText(state.bottomRight, state.copyFeedback)
85 fittedMRU := fitMRU(state.mru, len(row), x, rightText)91 fittedMRU := fitMRU(state.mru, len(row), x, rightText)
86 for i, sess := range fittedMRU {92 for i, sess := range fittedMRU {
87 style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU))93 style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU))
@@ -99,6 +105,25 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles)
99 drawRightPlainText(row, rightText, x, styles.statusBar)105 drawRightPlainText(row, rightText, x, styles.statusBar)
100 }106 }
101 107
108+func drawHistoryNavSessions(row []renderer.Cell, x *int, entries []historyNavSession, rightText string, attentionFrame int, styles themeStyles) {
109+ for _, sess := range fitHistoryNav(entries, len(row), *x, rightText) {
110+ style := styles.statusBar
111+ if sess.active {
112+ style = styles.accent
113+ }
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+ }
123+ writeStatusText(row, x, " ", style)
124+ }
125+}
126+
102 func composeBottomRightText(scriptText, copyFeedback string) string {127 func composeBottomRightText(scriptText, copyFeedback string) string {
103 if scriptText == "" {128 if scriptText == "" {
104 return copyFeedback129 return copyFeedback
@@ -138,6 +163,7 @@ type barState struct {
138 bottomRight string163 bottomRight string
139 copyFeedback string164 copyFeedback string
140 mru []mruSession165 mru []mruSession
166+ history []historyNavSession
141 attentionFrame int167 attentionFrame int
142 // theme is the client's terminal theme, if reported. Its zero value168 // theme is the client's terminal theme, if reported. Its zero value
143 // (Theme{}, Known: false) is a valid "no theme" default that resolves to169 // (Theme{}, Known: false) is a valid "no theme" default that resolves to
@@ -154,6 +180,14 @@ type mruSession struct {
154 mruAt uint64180 mruAt uint64
155 }181 }
156 182
183+type historyNavSession struct {
184+ id domain.SessionID
185+ name string
186+ ephemeral bool
187+ attention bool
188+ active bool
189+}
190+
157 type statusSnapshot struct {191 type statusSnapshot struct {
158 session string192 session string
159 tabs []statusTab193 tabs []statusTab
@@ -180,6 +214,53 @@ func (s *session) statusSegments() statusSnapshot {
180 return snap214 return snap
181 }215 }
182 216
217+func (d *Daemon) barStateForClient(cur *session, ac *attachedClient, copyFeedback string) barState {
218+ state := d.barStateFor(cur, copyFeedback)
219+ state.history = d.historyNavBarState(ac)
220+ return state
221+}
222+
223+func (d *Daemon) historyNavBarState(ac *attachedClient) []historyNavSession {
224+ if d == nil || ac == nil {
225+ return nil
226+ }
227+ ac.historyNavMu.Lock()
228+ if !ac.historyNav.active() {
229+ ac.historyNavMu.Unlock()
230+ return nil
231+ }
232+ ids := append([]domain.SessionID(nil), ac.historyNav.ids...)
233+ activeIndex := ac.historyNav.index
234+ ac.historyNavMu.Unlock()
235+
236+ d.mu.Lock()
237+ out := make([]historyNavSession, 0, len(ids))
238+ valid := true
239+ for i, id := range ids {
240+ sess := d.sessions[id]
241+ if sess == nil {
242+ valid = false
243+ break
244+ }
245+ sess.mu.Lock()
246+ entry := historyNavSession{id: sess.id, name: sess.name, ephemeral: sess.ephemeral, active: i == activeIndex}
247+ for _, tb := range sess.tabs {
248+ if tb.attention {
249+ entry.attention = true
250+ break
251+ }
252+ }
253+ sess.mu.Unlock()
254+ out = append(out, entry)
255+ }
256+ d.mu.Unlock()
257+ if !valid {
258+ d.clearHistoryNav(ac)
259+ return nil
260+ }
261+ return out
262+}
263+
183 func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {264 func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
184 state := barState{copyFeedback: copyFeedback}265 state := barState{copyFeedback: copyFeedback}
185 if d != nil {266 if d != nil {
@@ -226,6 +307,70 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
226 return state307 return state
227 }308 }
228 309
310+func fitHistoryNav(entries []historyNavSession, rowLen, leftUsed int, feedback string) []historyNavSession {
311+ copyReserve := 1
312+ if feedback != "" {
313+ copyReserve = statusTextWidth(feedback) + 2
314+ }
315+ budget := rowLen - leftUsed - copyReserve
316+ if budget <= 0 || len(entries) == 0 {
317+ return nil
318+ }
319+ cost := func(e historyNavSession) int {
320+ name := e.name
321+ if e.ephemeral {
322+ name += "*"
323+ }
324+ n := 2 + statusTextWidth(name)
325+ if e.attention {
326+ n += 1 + renderer.RuneWidth(attentionGlyph)
327+ }
328+ return n
329+ }
330+ used := 0
331+ for _, e := range entries {
332+ used += cost(e)
333+ }
334+ if used <= budget {
335+ return entries
336+ }
337+ active := 0
338+ for i, e := range entries {
339+ if e.active {
340+ active = i
341+ break
342+ }
343+ }
344+ if cost(entries[active]) > budget {
345+ return nil
346+ }
347+ start, end := active, active+1
348+ used = cost(entries[active])
349+ for {
350+ expanded := false
351+ if start > 0 {
352+ c := cost(entries[start-1])
353+ if used+c <= budget {
354+ start--
355+ used += c
356+ expanded = true
357+ }
358+ }
359+ if end < len(entries) {
360+ c := cost(entries[end])
361+ if used+c <= budget {
362+ end++
363+ used += c
364+ expanded = true
365+ }
366+ }
367+ if !expanded {
368+ break
369+ }
370+ }
371+ return entries[start:end]
372+}
373+
229 func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSession {374 func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSession {
230 // With no feedback, keep one blank trailing cell; with feedback, reserve375 // With no feedback, keep one blank trailing cell; with feedback, reserve
231 // its " text" width plus a one-cell gap so drawRightPlainText always fits.376 // 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 12

Tags 11

↑↓ moveenter openesc close