feat(daemon): wire rename prompt
- commit
- 26d6300d7e0495ddedec76d8623a1551e12ddb7e
- author
- brice · · 2026-07-03 09:27 UTC
- parent
- 7436891
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 11 | 11 | {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }}, | |
| 12 | 12 | {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }}, | |
| 13 | 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 | 15 | {Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }}, | |
| 16 | 16 | } | |
| 17 | 17 | ||
| @@ -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 ( | |||
| 37 | 37 | "github.com/bnema/vev/internal/usecase/mouse" | |
| 38 | 38 | "github.com/bnema/vev/internal/usecase/palette" | |
| 39 | 39 | "github.com/bnema/vev/internal/usecase/picker" | |
| 40 | + | promptui "github.com/bnema/vev/internal/usecase/prompt" | |
| 40 | 41 | "github.com/bnema/vev/pkg/renderer" | |
| 41 | 42 | ) | |
| 42 | 43 | ||
| @@ -60,6 +61,10 @@ type attachedClient struct { | |||
| 60 | 61 | paletteMu sync.Mutex | |
| 61 | 62 | palette *palette.Model | |
| 62 | 63 | palettePending []byte | |
| 64 | + | promptMu sync.Mutex | |
| 65 | + | prompt *promptui.Model | |
| 66 | + | promptSubmit func(string) error | |
| 67 | + | promptPending []byte | |
| 63 | 68 | mouseScan mouse.Scanner | |
| 64 | 69 | lastCursor cursorOut | |
| 65 | 70 | sendMu sync.Mutex | |
| @@ -133,6 +138,12 @@ func (ac *attachedClient) paletteActive() bool { | |||
| 133 | 138 | 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 | 147 | // send serialises a frame onto the client's transport. | |
| 137 | 148 | func (ac *attachedClient) send(f ports.Frame) error { | |
| 138 | 149 | 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.Mutex | 61 | paletteMu sync.Mutex | ||
| 61 | palette *palette.Model | 62 | palette *palette.Model | ||
| 62 | palettePending []byte | 63 | palettePending []byte | ||
| 64 | + | promptMu sync.Mutex | |||
| 65 | + | prompt *promptui.Model | |||
| 66 | + | promptSubmit func(string) error | |||
| 67 | + | promptPending []byte | |||
| 63 | mouseScan mouse.Scanner | 68 | mouseScan mouse.Scanner | ||
| 64 | lastCursor cursorOut | 69 | lastCursor cursorOut | ||
| 65 | sendMu sync.Mutex | 70 | sendMu sync.Mutex | ||
| @@ -133,6 +138,12 @@ func (ac *attachedClient) paletteActive() bool { | |||||
| 133 | return ac.palette != nil | 138 | 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) { | |||
| 34 | 34 | ac.mouseScan.Scan(data, | |
| 35 | 35 | func(ev mouse.Event) { d.handleMouse(ac, ev) }, | |
| 36 | 36 | func(b []byte) { | |
| 37 | + | if ac.promptActive() { | |
| 38 | + | d.handlePromptInput(ac, b) | |
| 39 | + | return | |
| 40 | + | } | |
| 37 | 41 | if ac.paletteActive() { | |
| 38 | 42 | d.handlePaletteInput(ac, b) | |
| 39 | 43 | return | |
| @@ -52,7 +56,7 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) { | |||
| 52 | 56 | } | |
| 53 | 57 | ||
| 54 | 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 | 60 | return | |
| 57 | 61 | } | |
| 58 | 62 | 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 | return | 43 | 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 | return | 60 | 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) { | |||
| 141 | 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 | 145 | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |
| 146 | 146 | defer releases[0]() | |
| 147 | 147 | sess.ephemeral = true | |
| 148 | 148 | sess.name = "0" | |
| 149 | 149 | ||
| 150 | 150 | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 151 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 151 | 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 | 160 | require.False(t, sess.ephemeral) | |
| 154 | 161 | require.Equal(t, "0", sess.name) | |
| 155 | - | awaitFrame(t, sends, ports.MsgOutput) | |
| 156 | 162 | } | |
| 157 | 163 | ||
| 158 | 164 | 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 = true | 147 | 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 { | |||
| 132 | 132 | } | |
| 133 | 133 | ||
| 134 | 134 | 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 | + | }) | |
| 137 | 141 | return nil | |
| 138 | 142 | } | |
| 139 | 143 | ||
| @@ -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 nil | 141 | return nil | ||
| 138 | } | 142 | } | ||
| 139 | 143 | ||||
M internal/usecase/daemon/palette_test.go +3 -1
| @@ -154,7 +154,9 @@ func TestPaletteExecMethods(t *testing.T) { | |||
| 154 | 154 | require.Equal(t, 0, activeTabIndex(sess)) | |
| 155 | 155 | sess.ephemeral = true | |
| 156 | 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 | 160 | require.NoError(t, exec.OpenSessionPicker()) | |
| 159 | 161 | require.True(t, ac.pickerActive()) | |
| 160 | 162 | 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 = true | 155 | 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) { | |||
| 263 | 263 | ac.paletteMu.Lock() | |
| 264 | 264 | paletteModel := ac.palette | |
| 265 | 265 | paletteActive := paletteModel != nil | |
| 266 | + | ac.paletteMu.Unlock() | |
| 267 | + | ac.promptMu.Lock() | |
| 268 | + | promptModel := ac.prompt | |
| 269 | + | promptActive := promptModel != nil | |
| 266 | 270 | ||
| 267 | 271 | tb.mu.Lock() | |
| 268 | - | if reset || copyActive || pickerActive || paletteActive { | |
| 272 | + | if reset || copyActive || pickerActive || paletteActive || promptActive { | |
| 269 | 273 | ac.rend.Reset() | |
| 270 | 274 | } | |
| 271 | - | if reset || pickerActive || paletteActive { | |
| 275 | + | if reset || pickerActive || paletteActive || promptActive { | |
| 272 | 276 | ac.lastCursor.valid = false | |
| 273 | 277 | } | |
| 274 | 278 | frame, damage := composeClientFrame(sess, tb, reset, copyFeedback) | |
| @@ -284,8 +288,11 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 284 | 288 | if paletteActive { | |
| 285 | 289 | frame, damage = composePaletteClientFrame(paletteModel, frame) | |
| 286 | 290 | } | |
| 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) | |
| 289 | 296 | data, err := ac.rend.Draw(frame, damage) | |
| 290 | 297 | var cursorTail []byte | |
| 291 | 298 | 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.palette | 264 | paletteModel := ac.palette | ||
| 265 | paletteActive := paletteModel != nil | 265 | 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 = false | 276 | 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 []byte | 297 | 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 | |||
| 27 | 27 | ||
| 28 | 28 | import ( | |
| 29 | 29 | "context" | |
| 30 | + | "errors" | |
| 30 | 31 | "fmt" | |
| 31 | 32 | "strconv" | |
| 32 | 33 | "strings" | |
| @@ -191,12 +192,20 @@ func (s *session) switchRelative(delta int) bool { | |||
| 191 | 192 | return true | |
| 192 | 193 | } | |
| 193 | 194 | ||
| 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 | |
| 200 | 209 | } | |
| 201 | 210 | ||
| 202 | 211 | 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 true | 192 | return true | ||
| 192 | } | 193 | } | ||
| 193 | 194 | ||||
| 194 | - | // promoteEphemeral is the promptless rename-session command behavior: without a | 195 | + | 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 = false | 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 | |||
| 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 { | |||
| 70 | 70 | func (s *session) statusSegments() statusSnapshot { | |
| 71 | 71 | s.mu.Lock() | |
| 72 | 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 | 78 | for i := range s.tabs { | |
| 75 | 79 | name := strconv.Itoa(i + 1) | |
| 76 | 80 | 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) { | |||
| 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 | 63 | func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) { | |
| 49 | 64 | p, releasePTY := newBlockingPTY(t) | |
| 50 | 65 | _, 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) | ||