detached 26d6300

feat(daemon): wire rename prompt

commit
26d6300d7e0495ddedec76d8623a1551e12ddb7e
author
brice · · 2026-07-03 09:27 UTC
parent
7436891
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +287 -18
M internal/usecase/command/registry.go +1 -1
@@ -11,7 +11,7 @@ func Registry() []Command {
1111 {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }},
1212 {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
1313 {Code: "CPY", Name: "Copy mode", Desc: "Enter copy mode", Run: func(ctx Context) error { return ctx.EnterCopyMode() }},
14- {Code: "RNS", Name: "Promote session", Desc: "Promote ephemeral session to named", Run: func(ctx Context) error { return ctx.RenameSession() }},
14+ {Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},
1515 {Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},
1616 }
1717
@@ -11,7 +11,7 @@ func Registry() []Command {
11 {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }},11 {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }},
12 {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},12 {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
13 {Code: "CPY", Name: "Copy mode", Desc: "Enter copy mode", Run: func(ctx Context) error { return ctx.EnterCopyMode() }},13 {Code: "CPY", Name: "Copy mode", Desc: "Enter copy mode", Run: func(ctx Context) error { return ctx.EnterCopyMode() }},
14- {Code: "RNS", Name: "Promote session", Desc: "Promote ephemeral session to named", Run: func(ctx Context) error { return ctx.RenameSession() }},14+ {Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},
15 {Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},15 {Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},
16 }16 }
17 17
M internal/usecase/daemon/client.go +11 -0
@@ -37,6 +37,7 @@ import (
3737 "github.com/bnema/vev/internal/usecase/mouse"
3838 "github.com/bnema/vev/internal/usecase/palette"
3939 "github.com/bnema/vev/internal/usecase/picker"
40+ promptui "github.com/bnema/vev/internal/usecase/prompt"
4041 "github.com/bnema/vev/pkg/renderer"
4142 )
4243
@@ -60,6 +61,10 @@ type attachedClient struct {
6061 paletteMu sync.Mutex
6162 palette *palette.Model
6263 palettePending []byte
64+ promptMu sync.Mutex
65+ prompt *promptui.Model
66+ promptSubmit func(string) error
67+ promptPending []byte
6368 mouseScan mouse.Scanner
6469 lastCursor cursorOut
6570 sendMu sync.Mutex
@@ -133,6 +138,12 @@ func (ac *attachedClient) paletteActive() bool {
133138 return ac.palette != nil
134139 }
135140
141+func (ac *attachedClient) promptActive() bool {
142+ ac.promptMu.Lock()
143+ defer ac.promptMu.Unlock()
144+ return ac.prompt != nil
145+}
146+
136147 // send serialises a frame onto the client's transport.
137148 func (ac *attachedClient) send(f ports.Frame) error {
138149 ac.sendMu.Lock()
@@ -37,6 +37,7 @@ import (
37 "github.com/bnema/vev/internal/usecase/mouse"37 "github.com/bnema/vev/internal/usecase/mouse"
38 "github.com/bnema/vev/internal/usecase/palette"38 "github.com/bnema/vev/internal/usecase/palette"
39 "github.com/bnema/vev/internal/usecase/picker"39 "github.com/bnema/vev/internal/usecase/picker"
40+ promptui "github.com/bnema/vev/internal/usecase/prompt"
40 "github.com/bnema/vev/pkg/renderer"41 "github.com/bnema/vev/pkg/renderer"
41 )42 )
42 43
@@ -60,6 +61,10 @@ type attachedClient struct {
60 paletteMu sync.Mutex61 paletteMu sync.Mutex
61 palette *palette.Model62 palette *palette.Model
62 palettePending []byte63 palettePending []byte
64+ promptMu sync.Mutex
65+ prompt *promptui.Model
66+ promptSubmit func(string) error
67+ promptPending []byte
63 mouseScan mouse.Scanner68 mouseScan mouse.Scanner
64 lastCursor cursorOut69 lastCursor cursorOut
65 sendMu sync.Mutex70 sendMu sync.Mutex
@@ -133,6 +138,12 @@ func (ac *attachedClient) paletteActive() bool {
133 return ac.palette != nil138 return ac.palette != nil
134 }139 }
135 140
141+func (ac *attachedClient) promptActive() bool {
142+ ac.promptMu.Lock()
143+ defer ac.promptMu.Unlock()
144+ return ac.prompt != nil
145+}
146+
136 // send serialises a frame onto the client's transport.147 // send serialises a frame onto the client's transport.
137 func (ac *attachedClient) send(f ports.Frame) error {148 func (ac *attachedClient) send(f ports.Frame) error {
138 ac.sendMu.Lock()149 ac.sendMu.Lock()
M internal/usecase/daemon/input.go +5 -1
@@ -34,6 +34,10 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
3434 ac.mouseScan.Scan(data,
3535 func(ev mouse.Event) { d.handleMouse(ac, ev) },
3636 func(b []byte) {
37+ if ac.promptActive() {
38+ d.handlePromptInput(ac, b)
39+ return
40+ }
3741 if ac.paletteActive() {
3842 d.handlePaletteInput(ac, b)
3943 return
@@ -52,7 +56,7 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
5256 }
5357
5458 func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
55- if ac.paletteActive() || ac.pickerActive() {
59+ if ac.promptActive() || ac.paletteActive() || ac.pickerActive() {
5660 return
5761 }
5862 sess := ac.currentSession()
@@ -34,6 +34,10 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
34 ac.mouseScan.Scan(data,34 ac.mouseScan.Scan(data,
35 func(ev mouse.Event) { d.handleMouse(ac, ev) },35 func(ev mouse.Event) { d.handleMouse(ac, ev) },
36 func(b []byte) {36 func(b []byte) {
37+ if ac.promptActive() {
38+ d.handlePromptInput(ac, b)
39+ return
40+ }
37 if ac.paletteActive() {41 if ac.paletteActive() {
38 d.handlePaletteInput(ac, b)42 d.handlePaletteInput(ac, b)
39 return43 return
@@ -52,7 +56,7 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
52 }56 }
53 57
54 func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {58 func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
55- if ac.paletteActive() || ac.pickerActive() {59+ if ac.promptActive() || ac.paletteActive() || ac.pickerActive() {
56 return60 return
57 }61 }
58 sess := ac.currentSession()62 sess := ac.currentSession()
M internal/usecase/daemon/input_test.go +8 -2
@@ -141,18 +141,24 @@ func TestAltDDetachesCurrentClient(t *testing.T) {
141141 require.Equal(t, ports.ReasonDetach, det.Reason)
142142 }
143143
144-func TestAltRPromotesEphemeralSessionPromptlessly(t *testing.T) {
144+func TestRNSOpensPromptAndEnterPromotesEphemeralSession(t *testing.T) {
145145 d, sess, ac, sends, releases := newManualTabSession(t, 1)
146146 defer releases[0]()
147147 sess.ephemeral = true
148148 sess.name = "0"
149149
150150 d.handleInput(sess, ac, []byte("\x1b "))
151+ awaitFrame(t, sends, ports.MsgOutput)
151152 d.handleInput(sess, ac, []byte("RNS\r"))
153+ awaitFrame(t, sends, ports.MsgOutput)
154+ require.True(t, ac.promptActive())
152155
156+ d.handleInput(sess, ac, []byte("\r"))
157+ awaitFrame(t, sends, ports.MsgOutput)
158+
159+ require.False(t, ac.promptActive())
153160 require.False(t, sess.ephemeral)
154161 require.Equal(t, "0", sess.name)
155- awaitFrame(t, sends, ports.MsgOutput)
156162 }
157163
158164 func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {
@@ -141,18 +141,24 @@ func TestAltDDetachesCurrentClient(t *testing.T) {
141 require.Equal(t, ports.ReasonDetach, det.Reason)141 require.Equal(t, ports.ReasonDetach, det.Reason)
142 }142 }
143 143
144-func TestAltRPromotesEphemeralSessionPromptlessly(t *testing.T) {144+func TestRNSOpensPromptAndEnterPromotesEphemeralSession(t *testing.T) {
145 d, sess, ac, sends, releases := newManualTabSession(t, 1)145 d, sess, ac, sends, releases := newManualTabSession(t, 1)
146 defer releases[0]()146 defer releases[0]()
147 sess.ephemeral = true147 sess.ephemeral = true
148 sess.name = "0"148 sess.name = "0"
149 149
150 d.handleInput(sess, ac, []byte("\x1b "))150 d.handleInput(sess, ac, []byte("\x1b "))
151+ awaitFrame(t, sends, ports.MsgOutput)
151 d.handleInput(sess, ac, []byte("RNS\r"))152 d.handleInput(sess, ac, []byte("RNS\r"))
153+ awaitFrame(t, sends, ports.MsgOutput)
154+ require.True(t, ac.promptActive())
152 155
156+ d.handleInput(sess, ac, []byte("\r"))
157+ awaitFrame(t, sends, ports.MsgOutput)
158+
159+ require.False(t, ac.promptActive())
153 require.False(t, sess.ephemeral)160 require.False(t, sess.ephemeral)
154 require.Equal(t, "0", sess.name)161 require.Equal(t, "0", sess.name)
155- awaitFrame(t, sends, ports.MsgOutput)
156 }162 }
157 163
158 func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {164 func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {
M internal/usecase/daemon/palette.go +6 -2
@@ -132,8 +132,12 @@ func (e paletteExec) EnterCopyMode() error {
132132 }
133133
134134 func (e paletteExec) RenameSession() error {
135- e.sess.promoteEphemeral()
136- e.d.paint(e.sess, e.ac, true)
135+ e.sess.mu.Lock()
136+ currentName := e.sess.name
137+ e.sess.mu.Unlock()
138+ e.d.enterPrompt(e.sess, e.ac, " Rename session ", currentName, func(name string) error {
139+ return e.d.renameSession(e.sess, name)
140+ })
137141 return nil
138142 }
139143
@@ -132,8 +132,12 @@ func (e paletteExec) EnterCopyMode() error {
132 }132 }
133 133
134 func (e paletteExec) RenameSession() error {134 func (e paletteExec) RenameSession() error {
135- e.sess.promoteEphemeral()135+ e.sess.mu.Lock()
136- e.d.paint(e.sess, e.ac, true)136+ currentName := e.sess.name
137+ e.sess.mu.Unlock()
138+ e.d.enterPrompt(e.sess, e.ac, " Rename session ", currentName, func(name string) error {
139+ return e.d.renameSession(e.sess, name)
140+ })
137 return nil141 return nil
138 }142 }
139 143
M internal/usecase/daemon/palette_test.go +3 -1
@@ -154,7 +154,9 @@ func TestPaletteExecMethods(t *testing.T) {
154154 require.Equal(t, 0, activeTabIndex(sess))
155155 sess.ephemeral = true
156156 require.NoError(t, exec.RenameSession())
157- require.False(t, sess.ephemeral)
157+ require.True(t, ac.promptActive())
158+ d.closePrompt(ac)
159+ require.True(t, sess.ephemeral)
158160 require.NoError(t, exec.OpenSessionPicker())
159161 require.True(t, ac.pickerActive())
160162 d.closePicker(ac)
@@ -154,7 +154,9 @@ func TestPaletteExecMethods(t *testing.T) {
154 require.Equal(t, 0, activeTabIndex(sess))154 require.Equal(t, 0, activeTabIndex(sess))
155 sess.ephemeral = true155 sess.ephemeral = true
156 require.NoError(t, exec.RenameSession())156 require.NoError(t, exec.RenameSession())
157- require.False(t, sess.ephemeral)157+ require.True(t, ac.promptActive())
158+ d.closePrompt(ac)
159+ require.True(t, sess.ephemeral)
158 require.NoError(t, exec.OpenSessionPicker())160 require.NoError(t, exec.OpenSessionPicker())
159 require.True(t, ac.pickerActive())161 require.True(t, ac.pickerActive())
160 d.closePicker(ac)162 d.closePicker(ac)
A internal/usecase/daemon/prompt.go +104 -0
@@ -0,0 +1,104 @@
1+package daemon
2+
3+import (
4+ "strings"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ promptui "github.com/bnema/vev/internal/usecase/prompt"
8+ "github.com/bnema/vev/internal/usecase/ui"
9+ "github.com/bnema/vev/pkg/renderer"
10+)
11+
12+func promptModalFor(title string) ui.Modal {
13+ return ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 4, Title: title, Anchor: ui.AnchorBottom, BottomMargin: 1}
14+}
15+
16+func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial string, submit func(string) error) {
17+ d.closePrompt(ac)
18+ ac.promptMu.Lock()
19+ ac.prompt = promptui.New(title, initial)
20+ ac.promptSubmit = submit
21+ ac.promptPending = nil
22+ ac.promptMu.Unlock()
23+ d.paint(sess, ac, true)
24+}
25+
26+func (d *Daemon) closePrompt(ac *attachedClient) {
27+ ac.promptMu.Lock()
28+ ac.prompt = nil
29+ ac.promptSubmit = nil
30+ ac.promptPending = nil
31+ ac.promptMu.Unlock()
32+}
33+
34+func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
35+ sess := ac.currentSession()
36+ if sess == nil {
37+ return
38+ }
39+
40+ ac.promptMu.Lock()
41+ if ac.prompt == nil {
42+ ac.promptPending = nil
43+ ac.promptMu.Unlock()
44+ return
45+ }
46+
47+ changed := false
48+ exit := false
49+ var submit func(string) error
50+ var value string
51+
52+ routeOverlayBytes(data, &ac.promptPending, overlayEvents{
53+ rune: func(r rune) {
54+ ac.prompt.Insert(r)
55+ changed = true
56+ },
57+ backspace: func() {
58+ ac.prompt.Backspace()
59+ changed = true
60+ },
61+ enter: func() {
62+ submit = ac.promptSubmit
63+ value = strings.TrimSpace(ac.prompt.Value())
64+ },
65+ cancel: func() { exit = true },
66+ up: func() {},
67+ down: func() {},
68+ })
69+ ac.promptMu.Unlock()
70+
71+ if submit != nil {
72+ if err := submit(value); err != nil {
73+ ac.promptMu.Lock()
74+ if ac.prompt != nil {
75+ ac.prompt.SetError(err.Error())
76+ }
77+ ac.promptMu.Unlock()
78+ d.paint(sess, ac, true)
79+ return
80+ }
81+ d.closePrompt(ac)
82+ d.paint(sess, ac, true)
83+ return
84+ }
85+ if exit {
86+ d.closePrompt(ac)
87+ d.paint(sess, ac, true)
88+ return
89+ }
90+ if changed {
91+ d.paint(sess, ac, true)
92+ }
93+}
94+
95+func composePromptClientFrame(model *promptui.Model, base renderer.Frame) (renderer.Frame, []renderer.Damage) {
96+ inner := promptModalFor(model.Title()).Composite(base, renderer.DefaultStyle())
97+ modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height})
98+ for y := range min(inner.Height, modalFrame.Height) {
99+ for x := range min(inner.Width, modalFrame.Width) {
100+ base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
101+ }
102+ }
103+ return base, []renderer.Damage{renderer.FullRedraw()}
104+}
@@ -0,0 +1,104 @@
1+package daemon
2+
3+import (
4+ "strings"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ promptui "github.com/bnema/vev/internal/usecase/prompt"
8+ "github.com/bnema/vev/internal/usecase/ui"
9+ "github.com/bnema/vev/pkg/renderer"
10+)
11+
12+func promptModalFor(title string) ui.Modal {
13+ return ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 4, Title: title, Anchor: ui.AnchorBottom, BottomMargin: 1}
14+}
15+
16+func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial string, submit func(string) error) {
17+ d.closePrompt(ac)
18+ ac.promptMu.Lock()
19+ ac.prompt = promptui.New(title, initial)
20+ ac.promptSubmit = submit
21+ ac.promptPending = nil
22+ ac.promptMu.Unlock()
23+ d.paint(sess, ac, true)
24+}
25+
26+func (d *Daemon) closePrompt(ac *attachedClient) {
27+ ac.promptMu.Lock()
28+ ac.prompt = nil
29+ ac.promptSubmit = nil
30+ ac.promptPending = nil
31+ ac.promptMu.Unlock()
32+}
33+
34+func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
35+ sess := ac.currentSession()
36+ if sess == nil {
37+ return
38+ }
39+
40+ ac.promptMu.Lock()
41+ if ac.prompt == nil {
42+ ac.promptPending = nil
43+ ac.promptMu.Unlock()
44+ return
45+ }
46+
47+ changed := false
48+ exit := false
49+ var submit func(string) error
50+ var value string
51+
52+ routeOverlayBytes(data, &ac.promptPending, overlayEvents{
53+ rune: func(r rune) {
54+ ac.prompt.Insert(r)
55+ changed = true
56+ },
57+ backspace: func() {
58+ ac.prompt.Backspace()
59+ changed = true
60+ },
61+ enter: func() {
62+ submit = ac.promptSubmit
63+ value = strings.TrimSpace(ac.prompt.Value())
64+ },
65+ cancel: func() { exit = true },
66+ up: func() {},
67+ down: func() {},
68+ })
69+ ac.promptMu.Unlock()
70+
71+ if submit != nil {
72+ if err := submit(value); err != nil {
73+ ac.promptMu.Lock()
74+ if ac.prompt != nil {
75+ ac.prompt.SetError(err.Error())
76+ }
77+ ac.promptMu.Unlock()
78+ d.paint(sess, ac, true)
79+ return
80+ }
81+ d.closePrompt(ac)
82+ d.paint(sess, ac, true)
83+ return
84+ }
85+ if exit {
86+ d.closePrompt(ac)
87+ d.paint(sess, ac, true)
88+ return
89+ }
90+ if changed {
91+ d.paint(sess, ac, true)
92+ }
93+}
94+
95+func composePromptClientFrame(model *promptui.Model, base renderer.Frame) (renderer.Frame, []renderer.Damage) {
96+ inner := promptModalFor(model.Title()).Composite(base, renderer.DefaultStyle())
97+ modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height})
98+ for y := range min(inner.Height, modalFrame.Height) {
99+ for x := range min(inner.Width, modalFrame.Width) {
100+ base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
101+ }
102+ }
103+ return base, []renderer.Damage{renderer.FullRedraw()}
104+}
A internal/usecase/daemon/prompt_test.go +103 -0
@@ -0,0 +1,103 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/pkg/vt"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestEnterPromptRendersTitleAndPrefill(t *testing.T) {
14+ p, release := newBlockingPTY(t)
15+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
16+ defer release()
17+
18+ d.enterPrompt(sess, ac, " Rename session ", "0", func(string) error { return nil })
19+ out := awaitFrame(t, sends, ports.MsgOutput)
20+ msg, err := ports.UnmarshalOutput(out.Payload)
21+ require.NoError(t, err)
22+ require.Contains(t, string(msg.Data), "Rename session")
23+ require.Contains(t, string(msg.Data), "> 0")
24+ require.True(t, ac.promptActive())
25+}
26+
27+func TestPromptSubmitRenamesAndPromotesSession(t *testing.T) {
28+ p, release := newBlockingPTY(t)
29+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
30+ defer release()
31+ sess.name = "0"
32+ sess.ephemeral = true
33+
34+ d.enterPrompt(sess, ac, " Rename session ", sess.name, func(name string) error { return d.renameSession(sess, name) })
35+ awaitFrame(t, sends, ports.MsgOutput)
36+ d.handlePromptInput(ac, []byte("work\r"))
37+ repaint := awaitFrame(t, sends, ports.MsgOutput)
38+
39+ require.False(t, ac.promptActive())
40+ require.Equal(t, "0work", sess.name)
41+ require.False(t, sess.ephemeral)
42+ out, err := ports.UnmarshalOutput(repaint.Payload)
43+ require.NoError(t, err)
44+ require.Contains(t, string(out.Data), "0work")
45+ require.NotContains(t, string(out.Data), "0work*")
46+}
47+
48+func TestPromptSubmitErrorKeepsPromptOpen(t *testing.T) {
49+ p1, release1 := newBlockingPTY(t)
50+ p2, release2 := newBlockingPTY(t)
51+ d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
52+ defer release1()
53+ defer release2()
54+ ctx, cancel := context.WithCancel(context.Background())
55+ defer cancel()
56+ d.sessions["other"] = &session{id: "other", name: "taken", ctx: ctx, cancel: cancel, tabs: []*tab{{pty: p2, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: ctx, cancel: cancel}}}
57+
58+ d.enterPrompt(sess, ac, " Rename session ", "", func(name string) error { return d.renameSession(sess, name) })
59+ awaitFrame(t, sends, ports.MsgOutput)
60+ d.handlePromptInput(ac, []byte("taken\r"))
61+ repaint := awaitFrame(t, sends, ports.MsgOutput)
62+
63+ require.True(t, ac.promptActive())
64+ require.NotEqual(t, "taken", sess.name)
65+ out, err := ports.UnmarshalOutput(repaint.Payload)
66+ require.NoError(t, err)
67+ require.Contains(t, string(out.Data), "name already in use")
68+}
69+
70+func TestPromptEscapeCancelsWithoutRename(t *testing.T) {
71+ p, release := newBlockingPTY(t)
72+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
73+ defer release()
74+ oldName := sess.name
75+
76+ d.enterPrompt(sess, ac, " Rename session ", oldName, func(name string) error { return d.renameSession(sess, name) })
77+ awaitFrame(t, sends, ports.MsgOutput)
78+ d.handlePromptInput(ac, []byte("new\x1b"))
79+ awaitFrame(t, sends, ports.MsgOutput)
80+
81+ require.False(t, ac.promptActive())
82+ require.Equal(t, oldName, sess.name)
83+}
84+
85+func TestPaletteRNSOpensRenamePrompt(t *testing.T) {
86+ p, release := newBlockingPTY(t)
87+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
88+ defer release()
89+ sess.name = "0"
90+ sess.ephemeral = true
91+
92+ d.enterPalette(sess, ac)
93+ awaitFrame(t, sends, ports.MsgOutput)
94+ d.handlePaletteInput(ac, []byte("RNS\r"))
95+ out := awaitFrame(t, sends, ports.MsgOutput)
96+
97+ require.False(t, ac.paletteActive())
98+ require.True(t, ac.promptActive())
99+ msg, err := ports.UnmarshalOutput(out.Payload)
100+ require.NoError(t, err)
101+ require.Contains(t, string(msg.Data), "Rename session")
102+ require.Contains(t, string(msg.Data), "> 0")
103+}
@@ -0,0 +1,103 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/pkg/vt"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestEnterPromptRendersTitleAndPrefill(t *testing.T) {
14+ p, release := newBlockingPTY(t)
15+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
16+ defer release()
17+
18+ d.enterPrompt(sess, ac, " Rename session ", "0", func(string) error { return nil })
19+ out := awaitFrame(t, sends, ports.MsgOutput)
20+ msg, err := ports.UnmarshalOutput(out.Payload)
21+ require.NoError(t, err)
22+ require.Contains(t, string(msg.Data), "Rename session")
23+ require.Contains(t, string(msg.Data), "> 0")
24+ require.True(t, ac.promptActive())
25+}
26+
27+func TestPromptSubmitRenamesAndPromotesSession(t *testing.T) {
28+ p, release := newBlockingPTY(t)
29+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
30+ defer release()
31+ sess.name = "0"
32+ sess.ephemeral = true
33+
34+ d.enterPrompt(sess, ac, " Rename session ", sess.name, func(name string) error { return d.renameSession(sess, name) })
35+ awaitFrame(t, sends, ports.MsgOutput)
36+ d.handlePromptInput(ac, []byte("work\r"))
37+ repaint := awaitFrame(t, sends, ports.MsgOutput)
38+
39+ require.False(t, ac.promptActive())
40+ require.Equal(t, "0work", sess.name)
41+ require.False(t, sess.ephemeral)
42+ out, err := ports.UnmarshalOutput(repaint.Payload)
43+ require.NoError(t, err)
44+ require.Contains(t, string(out.Data), "0work")
45+ require.NotContains(t, string(out.Data), "0work*")
46+}
47+
48+func TestPromptSubmitErrorKeepsPromptOpen(t *testing.T) {
49+ p1, release1 := newBlockingPTY(t)
50+ p2, release2 := newBlockingPTY(t)
51+ d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
52+ defer release1()
53+ defer release2()
54+ ctx, cancel := context.WithCancel(context.Background())
55+ defer cancel()
56+ d.sessions["other"] = &session{id: "other", name: "taken", ctx: ctx, cancel: cancel, tabs: []*tab{{pty: p2, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: ctx, cancel: cancel}}}
57+
58+ d.enterPrompt(sess, ac, " Rename session ", "", func(name string) error { return d.renameSession(sess, name) })
59+ awaitFrame(t, sends, ports.MsgOutput)
60+ d.handlePromptInput(ac, []byte("taken\r"))
61+ repaint := awaitFrame(t, sends, ports.MsgOutput)
62+
63+ require.True(t, ac.promptActive())
64+ require.NotEqual(t, "taken", sess.name)
65+ out, err := ports.UnmarshalOutput(repaint.Payload)
66+ require.NoError(t, err)
67+ require.Contains(t, string(out.Data), "name already in use")
68+}
69+
70+func TestPromptEscapeCancelsWithoutRename(t *testing.T) {
71+ p, release := newBlockingPTY(t)
72+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
73+ defer release()
74+ oldName := sess.name
75+
76+ d.enterPrompt(sess, ac, " Rename session ", oldName, func(name string) error { return d.renameSession(sess, name) })
77+ awaitFrame(t, sends, ports.MsgOutput)
78+ d.handlePromptInput(ac, []byte("new\x1b"))
79+ awaitFrame(t, sends, ports.MsgOutput)
80+
81+ require.False(t, ac.promptActive())
82+ require.Equal(t, oldName, sess.name)
83+}
84+
85+func TestPaletteRNSOpensRenamePrompt(t *testing.T) {
86+ p, release := newBlockingPTY(t)
87+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
88+ defer release()
89+ sess.name = "0"
90+ sess.ephemeral = true
91+
92+ d.enterPalette(sess, ac)
93+ awaitFrame(t, sends, ports.MsgOutput)
94+ d.handlePaletteInput(ac, []byte("RNS\r"))
95+ out := awaitFrame(t, sends, ports.MsgOutput)
96+
97+ require.False(t, ac.paletteActive())
98+ require.True(t, ac.promptActive())
99+ msg, err := ports.UnmarshalOutput(out.Payload)
100+ require.NoError(t, err)
101+ require.Contains(t, string(msg.Data), "Rename session")
102+ require.Contains(t, string(msg.Data), "> 0")
103+}
M internal/usecase/daemon/render.go +11 -4
@@ -263,12 +263,16 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
263263 ac.paletteMu.Lock()
264264 paletteModel := ac.palette
265265 paletteActive := paletteModel != nil
266+ ac.paletteMu.Unlock()
267+ ac.promptMu.Lock()
268+ promptModel := ac.prompt
269+ promptActive := promptModel != nil
266270
267271 tb.mu.Lock()
268- if reset || copyActive || pickerActive || paletteActive {
272+ if reset || copyActive || pickerActive || paletteActive || promptActive {
269273 ac.rend.Reset()
270274 }
271- if reset || pickerActive || paletteActive {
275+ if reset || pickerActive || paletteActive || promptActive {
272276 ac.lastCursor.valid = false
273277 }
274278 frame, damage := composeClientFrame(sess, tb, reset, copyFeedback)
@@ -284,8 +288,11 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
284288 if paletteActive {
285289 frame, damage = composePaletteClientFrame(paletteModel, frame)
286290 }
287- ac.paletteMu.Unlock()
288- desiredCursor := desiredCursorOut(tb.screen, copyActive || pickerActive || paletteActive)
291+ if promptActive {
292+ frame, damage = composePromptClientFrame(promptModel, frame)
293+ }
294+ ac.promptMu.Unlock()
295+ desiredCursor := desiredCursorOut(tb.screen, copyActive || pickerActive || paletteActive || promptActive)
289296 data, err := ac.rend.Draw(frame, damage)
290297 var cursorTail []byte
291298 if err == nil {
@@ -263,12 +263,16 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
263 ac.paletteMu.Lock()263 ac.paletteMu.Lock()
264 paletteModel := ac.palette264 paletteModel := ac.palette
265 paletteActive := paletteModel != nil265 paletteActive := paletteModel != nil
266+ ac.paletteMu.Unlock()
267+ ac.promptMu.Lock()
268+ promptModel := ac.prompt
269+ promptActive := promptModel != nil
266 270
267 tb.mu.Lock()271 tb.mu.Lock()
268- if reset || copyActive || pickerActive || paletteActive {272+ if reset || copyActive || pickerActive || paletteActive || promptActive {
269 ac.rend.Reset()273 ac.rend.Reset()
270 }274 }
271- if reset || pickerActive || paletteActive {275+ if reset || pickerActive || paletteActive || promptActive {
272 ac.lastCursor.valid = false276 ac.lastCursor.valid = false
273 }277 }
274 frame, damage := composeClientFrame(sess, tb, reset, copyFeedback)278 frame, damage := composeClientFrame(sess, tb, reset, copyFeedback)
@@ -284,8 +288,11 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
284 if paletteActive {288 if paletteActive {
285 frame, damage = composePaletteClientFrame(paletteModel, frame)289 frame, damage = composePaletteClientFrame(paletteModel, frame)
286 }290 }
287- ac.paletteMu.Unlock()291+ if promptActive {
288- desiredCursor := desiredCursorOut(tb.screen, copyActive || pickerActive || paletteActive)292+ frame, damage = composePromptClientFrame(promptModel, frame)
293+ }
294+ ac.promptMu.Unlock()
295+ desiredCursor := desiredCursorOut(tb.screen, copyActive || pickerActive || paletteActive || promptActive)
289 data, err := ac.rend.Draw(frame, damage)296 data, err := ac.rend.Draw(frame, damage)
290 var cursorTail []byte297 var cursorTail []byte
291 if err == nil {298 if err == nil {
M internal/usecase/daemon/session.go +15 -6
@@ -27,6 +27,7 @@ package daemon
2727
2828 import (
2929 "context"
30+ "errors"
3031 "fmt"
3132 "strconv"
3233 "strings"
@@ -191,12 +192,20 @@ func (s *session) switchRelative(delta int) bool {
191192 return true
192193 }
193194
194-// promoteEphemeral is the promptless rename-session command behavior: without a
195-// naming UI yet, the current ephemeral numeric name is kept and the session is made persistent.
196-func (s *session) promoteEphemeral() {
197- s.mu.Lock()
198- defer s.mu.Unlock()
199- s.ephemeral = false
195+func (d *Daemon) renameSession(sess *session, name string) error {
196+ if name == "" {
197+ return errors.New("name required")
198+ }
199+ d.mu.Lock()
200+ defer d.mu.Unlock()
201+ if taken := d.findByNameLocked(name); taken != nil && taken != sess {
202+ return errors.New("name already in use")
203+ }
204+ sess.mu.Lock()
205+ defer sess.mu.Unlock()
206+ sess.name = name
207+ sess.ephemeral = false
208+ return nil
200209 }
201210
202211 func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
@@ -27,6 +27,7 @@ package daemon
27 27
28 import (28 import (
29 "context"29 "context"
30+ "errors"
30 "fmt"31 "fmt"
31 "strconv"32 "strconv"
32 "strings"33 "strings"
@@ -191,12 +192,20 @@ func (s *session) switchRelative(delta int) bool {
191 return true192 return true
192 }193 }
193 194
194-// promoteEphemeral is the promptless rename-session command behavior: without a195+func (d *Daemon) renameSession(sess *session, name string) error {
195-// naming UI yet, the current ephemeral numeric name is kept and the session is made persistent.196+ if name == "" {
196-func (s *session) promoteEphemeral() {197+ return errors.New("name required")
197- s.mu.Lock()198+ }
198- defer s.mu.Unlock()199+ d.mu.Lock()
199- s.ephemeral = false200+ defer d.mu.Unlock()
201+ if taken := d.findByNameLocked(name); taken != nil && taken != sess {
202+ return errors.New("name already in use")
203+ }
204+ sess.mu.Lock()
205+ defer sess.mu.Unlock()
206+ sess.name = name
207+ sess.ephemeral = false
208+ return nil
200 }209 }
201 210
202 func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {211 func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
M internal/usecase/daemon/status.go +5 -1
@@ -70,7 +70,11 @@ type statusTab struct {
7070 func (s *session) statusSegments() statusSnapshot {
7171 s.mu.Lock()
7272 defer s.mu.Unlock()
73- snap := statusSnapshot{session: s.name, tabs: make([]statusTab, len(s.tabs))}
73+ name := s.name
74+ if s.ephemeral {
75+ name += "*"
76+ }
77+ snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
7478 for i := range s.tabs {
7579 name := strconv.Itoa(i + 1)
7680 snap.tabs[i] = statusTab{name: name, active: i == s.active}
@@ -70,7 +70,11 @@ type statusTab struct {
70 func (s *session) statusSegments() statusSnapshot {70 func (s *session) statusSegments() statusSnapshot {
71 s.mu.Lock()71 s.mu.Lock()
72 defer s.mu.Unlock()72 defer s.mu.Unlock()
73- snap := statusSnapshot{session: s.name, tabs: make([]statusTab, len(s.tabs))}73+ name := s.name
74+ if s.ephemeral {
75+ name += "*"
76+ }
77+ snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
74 for i := range s.tabs {78 for i := range s.tabs {
75 name := strconv.Itoa(i + 1)79 name := strconv.Itoa(i + 1)
76 snap.tabs[i] = statusTab{name: name, active: i == s.active}80 snap.tabs[i] = statusTab{name: name, active: i == s.active}
M internal/usecase/daemon/status_test.go +15 -0
@@ -45,6 +45,21 @@ func TestStatusCompositionGolden(t *testing.T) {
4545 }
4646 }
4747
48+func TestStatusMarksEphemeralSession(t *testing.T) {
49+ p, release := newBlockingPTY(t)
50+ _, sess, _, _ := newManualSessionWithPTYs(t, p)
51+ defer release()
52+ sess.name = "0"
53+ sess.ephemeral = true
54+ win := sess.activeTab()
55+ win.screen = vt.NewScreen(12, 2)
56+ win.size = domain.Size{Cols: 12, Rows: 2}
57+
58+ frame, _ := composeClientFrame(sess, win, true, "")
59+
60+ require.Equal(t, " 0* 1 ", rowText(frame.Row(2)))
61+}
62+
4863 func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
4964 p, releasePTY := newBlockingPTY(t)
5065 _, sess, _, _ := newManualSessionWithPTYs(t, p)
@@ -45,6 +45,21 @@ func TestStatusCompositionGolden(t *testing.T) {
45 }45 }
46 }46 }
47 47
48+func TestStatusMarksEphemeralSession(t *testing.T) {
49+ p, release := newBlockingPTY(t)
50+ _, sess, _, _ := newManualSessionWithPTYs(t, p)
51+ defer release()
52+ sess.name = "0"
53+ sess.ephemeral = true
54+ win := sess.activeTab()
55+ win.screen = vt.NewScreen(12, 2)
56+ win.size = domain.Size{Cols: 12, Rows: 2}
57+
58+ frame, _ := composeClientFrame(sess, win, true, "")
59+
60+ require.Equal(t, " 0* 1 ", rowText(frame.Row(2)))
61+}
62+
48 func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {63 func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
49 p, releasePTY := newBlockingPTY(t)64 p, releasePTY := newBlockingPTY(t)
50 _, sess, _, _ := newManualSessionWithPTYs(t, p)65 _, sess, _, _ := newManualSessionWithPTYs(t, p)

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