detached 7436891

feat(prompt): add input prompt model

commit
7436891653b4f985bcc9bb9019794bda9d0497ce
author
brice · · 2026-07-03 09:21 UTC
parent
589da7e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +127 -0
A internal/usecase/prompt/model.go +69 -0
@@ -0,0 +1,69 @@
1+package prompt
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/ui"
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+type Model struct {
10+ title string
11+ text ui.TextInput
12+ errMsg string
13+}
14+
15+func New(title, initial string) *Model {
16+ m := &Model{title: title}
17+ m.text.SetValue(initial)
18+ return m
19+}
20+
21+func (m *Model) Insert(r rune) {
22+ if m == nil {
23+ return
24+ }
25+ m.errMsg = ""
26+ m.text.Insert(r)
27+}
28+
29+func (m *Model) Backspace() {
30+ if m == nil {
31+ return
32+ }
33+ m.errMsg = ""
34+ m.text.Backspace()
35+}
36+
37+func (m *Model) Value() string {
38+ if m == nil {
39+ return ""
40+ }
41+ return m.text.Value()
42+}
43+
44+func (m *Model) Title() string {
45+ if m == nil {
46+ return ""
47+ }
48+ return m.title
49+}
50+
51+func (m *Model) SetError(msg string) {
52+ if m != nil {
53+ m.errMsg = msg
54+ }
55+}
56+
57+func (m *Model) Render(inner domain.Size) renderer.Frame {
58+ frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
59+ if frame.Width == 0 || frame.Height == 0 {
60+ return frame
61+ }
62+ base := renderer.DefaultStyle()
63+ ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
64+ ui.DrawInputLine(frame, 0, "> ", m.Value(), base)
65+ if frame.Height > 1 && m != nil && m.errMsg != "" {
66+ ui.DrawText(frame, 0, 1, frame.Width, m.errMsg, base)
67+ }
68+ return frame
69+}
@@ -0,0 +1,69 @@
1+package prompt
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/ui"
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+type Model struct {
10+ title string
11+ text ui.TextInput
12+ errMsg string
13+}
14+
15+func New(title, initial string) *Model {
16+ m := &Model{title: title}
17+ m.text.SetValue(initial)
18+ return m
19+}
20+
21+func (m *Model) Insert(r rune) {
22+ if m == nil {
23+ return
24+ }
25+ m.errMsg = ""
26+ m.text.Insert(r)
27+}
28+
29+func (m *Model) Backspace() {
30+ if m == nil {
31+ return
32+ }
33+ m.errMsg = ""
34+ m.text.Backspace()
35+}
36+
37+func (m *Model) Value() string {
38+ if m == nil {
39+ return ""
40+ }
41+ return m.text.Value()
42+}
43+
44+func (m *Model) Title() string {
45+ if m == nil {
46+ return ""
47+ }
48+ return m.title
49+}
50+
51+func (m *Model) SetError(msg string) {
52+ if m != nil {
53+ m.errMsg = msg
54+ }
55+}
56+
57+func (m *Model) Render(inner domain.Size) renderer.Frame {
58+ frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
59+ if frame.Width == 0 || frame.Height == 0 {
60+ return frame
61+ }
62+ base := renderer.DefaultStyle()
63+ ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
64+ ui.DrawInputLine(frame, 0, "> ", m.Value(), base)
65+ if frame.Height > 1 && m != nil && m.errMsg != "" {
66+ ui.DrawText(frame, 0, 1, frame.Width, m.errMsg, base)
67+ }
68+ return frame
69+}
A internal/usecase/prompt/model_test.go +58 -0
@@ -0,0 +1,58 @@
1+package prompt
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/pkg/renderer"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestModelPrefillAndEditingClearsError(t *testing.T) {
13+ m := New(" Rename session ", "0")
14+ require.Equal(t, " Rename session ", m.Title())
15+ require.Equal(t, "0", m.Value())
16+
17+ m.SetError("name already in use")
18+ m.Insert('x')
19+ require.Equal(t, "0x", m.Value())
20+ require.Empty(t, m.errMsg)
21+
22+ m.SetError("bad")
23+ m.Backspace()
24+ require.Equal(t, "0", m.Value())
25+ require.Empty(t, m.errMsg)
26+}
27+
28+func TestModelRenderDrawsInputAndError(t *testing.T) {
29+ m := New(" Rename session ", "work")
30+ m.SetError("name already in use")
31+
32+ frame := m.Render(domain.Size{Cols: 24, Rows: 2})
33+
34+ require.Equal(t, "> work ", promptRowText(frame.Row(0)))
35+ require.True(t, frame.At(6, 0).Style.Inverse, "caret follows prefilled value")
36+ require.Equal(t, "name already in use ", promptRowText(frame.Row(1)))
37+}
38+
39+func promptRowText(row []renderer.Cell) string {
40+ var b strings.Builder
41+ for _, c := range row {
42+ if c.Rune == 0 {
43+ b.WriteRune(' ')
44+ continue
45+ }
46+ b.WriteRune(c.Rune)
47+ }
48+ return b.String()
49+}
50+
51+func TestModelRenderHandlesTinySize(t *testing.T) {
52+ m := New(" Rename session ", "work")
53+
54+ frame := m.Render(domain.Size{Cols: 0, Rows: 0})
55+
56+ require.Equal(t, 0, frame.Width)
57+ require.Equal(t, 0, frame.Height)
58+}
@@ -0,0 +1,58 @@
1+package prompt
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/pkg/renderer"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestModelPrefillAndEditingClearsError(t *testing.T) {
13+ m := New(" Rename session ", "0")
14+ require.Equal(t, " Rename session ", m.Title())
15+ require.Equal(t, "0", m.Value())
16+
17+ m.SetError("name already in use")
18+ m.Insert('x')
19+ require.Equal(t, "0x", m.Value())
20+ require.Empty(t, m.errMsg)
21+
22+ m.SetError("bad")
23+ m.Backspace()
24+ require.Equal(t, "0", m.Value())
25+ require.Empty(t, m.errMsg)
26+}
27+
28+func TestModelRenderDrawsInputAndError(t *testing.T) {
29+ m := New(" Rename session ", "work")
30+ m.SetError("name already in use")
31+
32+ frame := m.Render(domain.Size{Cols: 24, Rows: 2})
33+
34+ require.Equal(t, "> work ", promptRowText(frame.Row(0)))
35+ require.True(t, frame.At(6, 0).Style.Inverse, "caret follows prefilled value")
36+ require.Equal(t, "name already in use ", promptRowText(frame.Row(1)))
37+}
38+
39+func promptRowText(row []renderer.Cell) string {
40+ var b strings.Builder
41+ for _, c := range row {
42+ if c.Rune == 0 {
43+ b.WriteRune(' ')
44+ continue
45+ }
46+ b.WriteRune(c.Rune)
47+ }
48+ return b.String()
49+}
50+
51+func TestModelRenderHandlesTinySize(t *testing.T) {
52+ m := New(" Rename session ", "work")
53+
54+ frame := m.Render(domain.Size{Cols: 0, Rows: 0})
55+
56+ require.Equal(t, 0, frame.Width)
57+ require.Equal(t, 0, frame.Height)
58+}

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