detached e9101ea

fix(modes): keep question dialog focus visible and answers consistent

commit
e9101ea6208478221b0767eb9ff4486580ea9f16
author
brice · · 2026-10-20 05:35 UTC
parent
68b21a6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
checks
0/3
3 files · +186 -7
M packages/agent/modes/interactive_input_routing.go +1 -0
@@ -57,6 +57,7 @@ func (i *Interactive) confirmChildActive() bool {
5757 func (i *Interactive) confirmChildActiveLocked() bool {
5858 return len(i.helpBlock) > 0 ||
5959 len(i.extNotes) > 0 ||
60+ i.questionDialog.Active() ||
6061 i.dialog.Active() ||
6162 i.modelDialog.Active() ||
6263 i.llamaDialog.Active() ||
@@ -57,6 +57,7 @@ func (i *Interactive) confirmChildActive() bool {
57 func (i *Interactive) confirmChildActiveLocked() bool {57 func (i *Interactive) confirmChildActiveLocked() bool {
58 return len(i.helpBlock) > 0 ||58 return len(i.helpBlock) > 0 ||
59 len(i.extNotes) > 0 ||59 len(i.extNotes) > 0 ||
60+ i.questionDialog.Active() ||
60 i.dialog.Active() ||61 i.dialog.Active() ||
61 i.modelDialog.Active() ||62 i.modelDialog.Active() ||
62 i.llamaDialog.Active() ||63 i.llamaDialog.Active() ||
M packages/agent/modes/question_dialog.go +29 -7
@@ -6,6 +6,7 @@ import (
66 "strings"
77 "sync"
88
9+ "github.com/bnema/zut/packages/agent/extproto"
910 "github.com/bnema/zut/packages/agent/tools"
1011 "github.com/bnema/zut/packages/tui"
1112 )
@@ -124,11 +125,16 @@ func (d *questionDialog) HandleKey(k tui.Key) {
124125 runes := []rune(s.text)
125126 if len(runes) > 0 {
126127 s.text = string(runes[:len(runes)-1])
128+ s.answered = false
127129 }
128130 case tui.KeyRune:
129- s.text += string(k.Rune)
131+ s.text += strings.NewReplacer("\r", " ", "\n", " ").Replace(string(k.Rune))
132+ s.answered = false
130133 case tui.KeyPaste:
131- s.text += k.Paste
134+ s.text += strings.NewReplacer("\r\n", " ", "\r", " ", "\n", " ").Replace(k.Paste)
135+ if k.Paste != "" {
136+ s.answered = false
137+ }
132138 }
133139 return
134140 }
@@ -150,6 +156,7 @@ func (d *questionDialog) HandleKey(k tui.Key) {
150156 s.cursor = n
151157 if q.MultiSelect {
152158 s.checked[n] = !s.checked[n]
159+ s.answered = false
153160 return
154161 }
155162 k.Kind = tui.KeyEnter
@@ -157,6 +164,7 @@ func (d *questionDialog) HandleKey(k tui.Key) {
157164 }
158165 if k.Kind == tui.KeyRune && k.Rune == ' ' && q.MultiSelect && s.cursor < len(q.Options) {
159166 s.checked[s.cursor] = !s.checked[s.cursor]
167+ s.answered = false
160168 return
161169 }
162170 if k.Kind != tui.KeyEnter {
@@ -198,12 +206,24 @@ func (d *questionDialog) Render(th tui.Theme, width int) []string {
198206 title += " · " + q.Header
199207 }
200208 lines := []string{frameHeader(th, title, width)}
201- add := func(text string) { lines = append(lines, tui.WrapANSILine(text, width)...) }
209+ // Like wrapDialogTextRows, split logical lines before wrapping, but keep
210+ // this dialog's unindented layout. Style physical rows after wrapping.
211+ addRows := func(text string, focused bool) {
212+ for _, line := range strings.Split(text, "\n") {
213+ for _, row := range tui.WrapANSILine(line, width) {
214+ if focused {
215+ row = th.PadHighlight(stripANSIBytes(row), width)
216+ }
217+ lines = append(lines, row)
218+ }
219+ }
220+ }
221+ add := func(text string) { addRows(text, false) }
202222 add(q.Question)
203223 lines = append(lines, "")
204224 footer := th.FGColor(th.Muted, "↑/↓ j/k move · 1–9 pick · Space toggle · Enter confirm · ←/Shift+Tab back · Tab next · Esc decline")
205225 if s.editing {
206- add("> " + s.text + "▏")
226+ addRows("> "+s.text+"▏", true)
207227 footer = th.FGColor(th.Muted, "Enter submit · Esc back/cancel · Shift+Tab previous")
208228 } else {
209229 for n := 0; n <= len(q.Options); n++ {
@@ -225,7 +245,7 @@ func (d *questionDialog) Render(th tui.Theme, width int) []string {
225245 if n == s.cursor {
226246 prefix = "› "
227247 }
228- add(prefix + label)
248+ addRows(prefix+label, n == s.cursor)
229249 }
230250 }
231251 if r.hint != "" {
@@ -237,7 +257,9 @@ func (d *questionDialog) Render(th tui.Theme, width int) []string {
237257 // Frame helpers may retain their minimum decoration width. Wrap these too.
238258 var out []string
239259 for _, line := range lines {
240- out = append(out, tui.WrapANSILine(line, width)...)
260+ for _, logical := range strings.Split(line, "\n") {
261+ out = append(out, tui.WrapANSILine(logical, width)...)
262+ }
241263 }
242264 return out
243265 }
@@ -248,7 +270,7 @@ func (i *Interactive) AskQuestions(ctx context.Context, qs []tools.Question) ([]
248270 return nil, err
249271 }
250272 r := i.questionDialog.Enqueue(qs)
251- i.scheduleMainAlert("waiting for answers")
273+ i.Alert("", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "waiting for answers"})
252274 i.invalidate()
253275 defer func() { i.questionDialog.Remove(r); i.invalidate() }()
254276 select {
@@ -6,6 +6,7 @@ import (
6 "strings"6 "strings"
7 "sync"7 "sync"
8 8
9+ "github.com/bnema/zut/packages/agent/extproto"
9 "github.com/bnema/zut/packages/agent/tools"10 "github.com/bnema/zut/packages/agent/tools"
10 "github.com/bnema/zut/packages/tui"11 "github.com/bnema/zut/packages/tui"
11 )12 )
@@ -124,11 +125,16 @@ func (d *questionDialog) HandleKey(k tui.Key) {
124 runes := []rune(s.text)125 runes := []rune(s.text)
125 if len(runes) > 0 {126 if len(runes) > 0 {
126 s.text = string(runes[:len(runes)-1])127 s.text = string(runes[:len(runes)-1])
128+ s.answered = false
127 }129 }
128 case tui.KeyRune:130 case tui.KeyRune:
129- s.text += string(k.Rune)131+ s.text += strings.NewReplacer("\r", " ", "\n", " ").Replace(string(k.Rune))
132+ s.answered = false
130 case tui.KeyPaste:133 case tui.KeyPaste:
131- s.text += k.Paste134+ s.text += strings.NewReplacer("\r\n", " ", "\r", " ", "\n", " ").Replace(k.Paste)
135+ if k.Paste != "" {
136+ s.answered = false
137+ }
132 }138 }
133 return139 return
134 }140 }
@@ -150,6 +156,7 @@ func (d *questionDialog) HandleKey(k tui.Key) {
150 s.cursor = n156 s.cursor = n
151 if q.MultiSelect {157 if q.MultiSelect {
152 s.checked[n] = !s.checked[n]158 s.checked[n] = !s.checked[n]
159+ s.answered = false
153 return160 return
154 }161 }
155 k.Kind = tui.KeyEnter162 k.Kind = tui.KeyEnter
@@ -157,6 +164,7 @@ func (d *questionDialog) HandleKey(k tui.Key) {
157 }164 }
158 if k.Kind == tui.KeyRune && k.Rune == ' ' && q.MultiSelect && s.cursor < len(q.Options) {165 if k.Kind == tui.KeyRune && k.Rune == ' ' && q.MultiSelect && s.cursor < len(q.Options) {
159 s.checked[s.cursor] = !s.checked[s.cursor]166 s.checked[s.cursor] = !s.checked[s.cursor]
167+ s.answered = false
160 return168 return
161 }169 }
162 if k.Kind != tui.KeyEnter {170 if k.Kind != tui.KeyEnter {
@@ -198,12 +206,24 @@ func (d *questionDialog) Render(th tui.Theme, width int) []string {
198 title += " · " + q.Header206 title += " · " + q.Header
199 }207 }
200 lines := []string{frameHeader(th, title, width)}208 lines := []string{frameHeader(th, title, width)}
201- add := func(text string) { lines = append(lines, tui.WrapANSILine(text, width)...) }209+ // Like wrapDialogTextRows, split logical lines before wrapping, but keep
210+ // this dialog's unindented layout. Style physical rows after wrapping.
211+ addRows := func(text string, focused bool) {
212+ for _, line := range strings.Split(text, "\n") {
213+ for _, row := range tui.WrapANSILine(line, width) {
214+ if focused {
215+ row = th.PadHighlight(stripANSIBytes(row), width)
216+ }
217+ lines = append(lines, row)
218+ }
219+ }
220+ }
221+ add := func(text string) { addRows(text, false) }
202 add(q.Question)222 add(q.Question)
203 lines = append(lines, "")223 lines = append(lines, "")
204 footer := th.FGColor(th.Muted, "↑/↓ j/k move · 1–9 pick · Space toggle · Enter confirm · ←/Shift+Tab back · Tab next · Esc decline")224 footer := th.FGColor(th.Muted, "↑/↓ j/k move · 1–9 pick · Space toggle · Enter confirm · ←/Shift+Tab back · Tab next · Esc decline")
205 if s.editing {225 if s.editing {
206- add("> " + s.text + "▏")226+ addRows("> "+s.text+"▏", true)
207 footer = th.FGColor(th.Muted, "Enter submit · Esc back/cancel · Shift+Tab previous")227 footer = th.FGColor(th.Muted, "Enter submit · Esc back/cancel · Shift+Tab previous")
208 } else {228 } else {
209 for n := 0; n <= len(q.Options); n++ {229 for n := 0; n <= len(q.Options); n++ {
@@ -225,7 +245,7 @@ func (d *questionDialog) Render(th tui.Theme, width int) []string {
225 if n == s.cursor {245 if n == s.cursor {
226 prefix = "› "246 prefix = "› "
227 }247 }
228- add(prefix + label)248+ addRows(prefix+label, n == s.cursor)
229 }249 }
230 }250 }
231 if r.hint != "" {251 if r.hint != "" {
@@ -237,7 +257,9 @@ func (d *questionDialog) Render(th tui.Theme, width int) []string {
237 // Frame helpers may retain their minimum decoration width. Wrap these too.257 // Frame helpers may retain their minimum decoration width. Wrap these too.
238 var out []string258 var out []string
239 for _, line := range lines {259 for _, line := range lines {
240- out = append(out, tui.WrapANSILine(line, width)...)260+ for _, logical := range strings.Split(line, "\n") {
261+ out = append(out, tui.WrapANSILine(logical, width)...)
262+ }
241 }263 }
242 return out264 return out
243 }265 }
@@ -248,7 +270,7 @@ func (i *Interactive) AskQuestions(ctx context.Context, qs []tools.Question) ([]
248 return nil, err270 return nil, err
249 }271 }
250 r := i.questionDialog.Enqueue(qs)272 r := i.questionDialog.Enqueue(qs)
251- i.scheduleMainAlert("waiting for answers")273+ i.Alert("", extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: "waiting for answers"})
252 i.invalidate()274 i.invalidate()
253 defer func() { i.questionDialog.Remove(r); i.invalidate() }()275 defer func() { i.questionDialog.Remove(r); i.invalidate() }()
254 select {276 select {
M packages/agent/modes/question_dialog_test.go +156 -0
@@ -153,3 +153,159 @@ func TestQuestionDialogSpacingInFloatingPane(t *testing.T) {
153153 t.Fatalf("unexpected spacing: %q", plain)
154154 }
155155 }
156+
157+func TestQuestionDialogFocusRows(t *testing.T) {
158+ d := newQuestionDialog()
159+ d.Enqueue([]tools.Question{{Question: "A long question that wraps over several rows", Options: []tools.QuestionOption{{Label: "A long option that wraps"}, {Label: "Last"}}}})
160+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
161+ assertFocus := func(want string) {
162+ t.Helper()
163+ _, body, _ := floatingDialogBody(d.Render(tui.Dark, 20))
164+ for _, row := range body {
165+ if strings.Contains(row, tui.Dark.SelectionStyle()) {
166+ if !strings.Contains(stripANSIBytes(row), want) {
167+ t.Fatalf("focus row = %q, want %q", row, want)
168+ }
169+ return
170+ }
171+ }
172+ t.Fatal("no detected focus row")
173+ }
174+ assertFocus("2. Last")
175+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
176+ assertFocus("Other…")
177+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
178+ d.HandleKey(tui.Key{Kind: tui.KeyPaste, Paste: "answer"})
179+ assertFocus("> answer▏")
180+}
181+
182+func TestQuestionDialogChangedSelectionRequiresConfirmation(t *testing.T) {
183+ for _, toggle := range []rune{' ', '1'} {
184+ t.Run(string(toggle), func(t *testing.T) {
185+ d := newQuestionDialog()
186+ r := d.Enqueue([]tools.Question{{Question: "Pick", MultiSelect: true, Options: []tools.QuestionOption{{Label: "A"}, {Label: "B"}}}, {Question: "Next"}})
187+ key := func(k tui.KeyKind) { d.HandleKey(tui.Key{Kind: k}) }
188+ pick := func(c rune) { d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: c}) }
189+ pick(toggle)
190+ key(tui.KeyEnter)
191+ key(tui.KeyShiftTab)
192+ pick(toggle)
193+ key(tui.KeyDown)
194+ if toggle == '1' {
195+ pick('2')
196+ } else {
197+ pick(' ')
198+ }
199+ for _, k := range []tui.KeyKind{tui.KeyTab, tui.KeyRight} {
200+ key(k)
201+ if r.index != 0 {
202+ t.Fatal("advanced with stale answer")
203+ }
204+ }
205+ key(tui.KeyEnter)
206+ if r.index != 1 || strings.Join(r.states[0].answer.Selected, ",") != "B" {
207+ t.Fatalf("did not recommit B: %+v", r.states[0])
208+ }
209+ })
210+ }
211+}
212+
213+func TestQuestionDialogEditedTextRequiresConfirmation(t *testing.T) {
214+ for _, edit := range []tui.Key{{Kind: tui.KeyBackspace}, {Kind: tui.KeyRune, Rune: 'x'}, {Kind: tui.KeyPaste, Paste: "new\ntext"}} {
215+ d := newQuestionDialog()
216+ r := d.Enqueue([]tools.Question{{Question: "Pick", Options: []tools.QuestionOption{{Label: "A"}}}, {Question: "Next"}})
217+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
218+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
219+ d.HandleKey(tui.Key{Kind: tui.KeyPaste, Paste: "old"})
220+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
221+ d.HandleKey(tui.Key{Kind: tui.KeyShiftTab})
222+ d.HandleKey(edit)
223+ d.HandleKey(tui.Key{Kind: tui.KeyEsc})
224+ d.HandleKey(tui.Key{Kind: tui.KeyTab})
225+ if r.index != 0 || r.states[0].answered {
226+ t.Fatalf("stale text accepted for %+v", edit)
227+ }
228+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
229+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
230+ if r.index != 1 || r.states[0].answer.Custom != r.states[0].text {
231+ t.Fatal("text not recommitted")
232+ }
233+ }
234+}
235+
236+func TestQuestionDialogMultilineRows(t *testing.T) {
237+ d := newQuestionDialog()
238+ r := d.Enqueue([]tools.Question{{Header: "H\nheader", Question: "Q\nquestion", Options: []tools.QuestionOption{{Label: "A\nlabel", Description: "D\ndescription"}}}})
239+ check := func() {
240+ t.Helper()
241+ for _, row := range d.Render(tui.Dark, 12) {
242+ if strings.Contains(row, "\n") {
243+ t.Fatalf("embedded newline: %q", row)
244+ }
245+ }
246+ }
247+ check()
248+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
249+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
250+ d.HandleKey(tui.Key{Kind: tui.KeyPaste, Paste: "one\r\ntwo\nthree\rfour"})
251+ if r.states[0].text != "one two three four" {
252+ t.Fatalf("paste = %q", r.states[0].text)
253+ }
254+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\n'})
255+ check()
256+ // Rendering also tolerates multiline state independently of input normalization.
257+ r.states[0].text = "typed\ntext"
258+ check()
259+}
260+
261+func TestQuestionDialogClipboardAndConfirmFocus(t *testing.T) {
262+ i := newConfirmationInputInteractive()
263+ r := i.questionDialog.Enqueue([]tools.Question{{Question: "Free text"}})
264+ if !i.confirmChildActive() {
265+ t.Fatal("question not recognized as clipboard child")
266+ }
267+ key, ok, err := resolveClipboardText(tui.Key{Kind: tui.KeyPasteClipboard}, func() (string, bool, error) { return "one\ntwo", true, nil })
268+ if err != nil || !ok || key.Kind != tui.KeyPaste {
269+ t.Fatalf("resolve: %+v %v", key, err)
270+ }
271+ i.handleKey(context.Background(), key)
272+ if r.states[0].text != "one two" || !i.ed.IsEmpty() {
273+ t.Fatal("paste not delivered to question input")
274+ }
275+ confirm := enqueueConfirmation(i)
276+ i.confirmDialog.Blur()
277+ i.restoreConfirmFocus()
278+ if i.confirmDialog.Focused() {
279+ t.Fatal("question stole focus restoration")
280+ }
281+ i.confirmDialog.Focus()
282+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
283+ if !(<-confirm).Allow {
284+ t.Fatal("confirmation focus broken")
285+ }
286+}
287+
288+func TestQuestionReadyAlertWhileBusy(t *testing.T) {
289+ for _, enabled := range []bool{true, false} {
290+ term := &alertTestTerminal{}
291+ i := &Interactive{cfg: InteractiveConfig{Terminal: term, TerminalAlertsEnabled: &enabled}, busy: true, questionDialog: newQuestionDialog(), dirty: make(chan struct{}, 1)}
292+ ctx, cancel := context.WithCancel(context.Background())
293+ done := make(chan error, 1)
294+ go func() { _, err := i.AskQuestions(ctx, []tools.Question{{Question: "Q"}}); done <- err }()
295+ <-i.dirty // Alert completes before invalidation; no redraw or timer needed.
296+ want := ""
297+ if enabled {
298+ want = "\a"
299+ }
300+ if got := term.String(); got != want {
301+ t.Errorf("busy alert = %q, want %q", got, want)
302+ }
303+ if i.pendingAlert != nil {
304+ t.Error("question alert deferred")
305+ }
306+ cancel()
307+ if err := <-done; !errors.Is(err, context.Canceled) {
308+ t.Fatal(err)
309+ }
310+ }
311+}
@@ -153,3 +153,159 @@ func TestQuestionDialogSpacingInFloatingPane(t *testing.T) {
153 t.Fatalf("unexpected spacing: %q", plain)153 t.Fatalf("unexpected spacing: %q", plain)
154 }154 }
155 }155 }
156+
157+func TestQuestionDialogFocusRows(t *testing.T) {
158+ d := newQuestionDialog()
159+ d.Enqueue([]tools.Question{{Question: "A long question that wraps over several rows", Options: []tools.QuestionOption{{Label: "A long option that wraps"}, {Label: "Last"}}}})
160+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
161+ assertFocus := func(want string) {
162+ t.Helper()
163+ _, body, _ := floatingDialogBody(d.Render(tui.Dark, 20))
164+ for _, row := range body {
165+ if strings.Contains(row, tui.Dark.SelectionStyle()) {
166+ if !strings.Contains(stripANSIBytes(row), want) {
167+ t.Fatalf("focus row = %q, want %q", row, want)
168+ }
169+ return
170+ }
171+ }
172+ t.Fatal("no detected focus row")
173+ }
174+ assertFocus("2. Last")
175+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
176+ assertFocus("Other…")
177+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
178+ d.HandleKey(tui.Key{Kind: tui.KeyPaste, Paste: "answer"})
179+ assertFocus("> answer▏")
180+}
181+
182+func TestQuestionDialogChangedSelectionRequiresConfirmation(t *testing.T) {
183+ for _, toggle := range []rune{' ', '1'} {
184+ t.Run(string(toggle), func(t *testing.T) {
185+ d := newQuestionDialog()
186+ r := d.Enqueue([]tools.Question{{Question: "Pick", MultiSelect: true, Options: []tools.QuestionOption{{Label: "A"}, {Label: "B"}}}, {Question: "Next"}})
187+ key := func(k tui.KeyKind) { d.HandleKey(tui.Key{Kind: k}) }
188+ pick := func(c rune) { d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: c}) }
189+ pick(toggle)
190+ key(tui.KeyEnter)
191+ key(tui.KeyShiftTab)
192+ pick(toggle)
193+ key(tui.KeyDown)
194+ if toggle == '1' {
195+ pick('2')
196+ } else {
197+ pick(' ')
198+ }
199+ for _, k := range []tui.KeyKind{tui.KeyTab, tui.KeyRight} {
200+ key(k)
201+ if r.index != 0 {
202+ t.Fatal("advanced with stale answer")
203+ }
204+ }
205+ key(tui.KeyEnter)
206+ if r.index != 1 || strings.Join(r.states[0].answer.Selected, ",") != "B" {
207+ t.Fatalf("did not recommit B: %+v", r.states[0])
208+ }
209+ })
210+ }
211+}
212+
213+func TestQuestionDialogEditedTextRequiresConfirmation(t *testing.T) {
214+ for _, edit := range []tui.Key{{Kind: tui.KeyBackspace}, {Kind: tui.KeyRune, Rune: 'x'}, {Kind: tui.KeyPaste, Paste: "new\ntext"}} {
215+ d := newQuestionDialog()
216+ r := d.Enqueue([]tools.Question{{Question: "Pick", Options: []tools.QuestionOption{{Label: "A"}}}, {Question: "Next"}})
217+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
218+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
219+ d.HandleKey(tui.Key{Kind: tui.KeyPaste, Paste: "old"})
220+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
221+ d.HandleKey(tui.Key{Kind: tui.KeyShiftTab})
222+ d.HandleKey(edit)
223+ d.HandleKey(tui.Key{Kind: tui.KeyEsc})
224+ d.HandleKey(tui.Key{Kind: tui.KeyTab})
225+ if r.index != 0 || r.states[0].answered {
226+ t.Fatalf("stale text accepted for %+v", edit)
227+ }
228+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
229+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
230+ if r.index != 1 || r.states[0].answer.Custom != r.states[0].text {
231+ t.Fatal("text not recommitted")
232+ }
233+ }
234+}
235+
236+func TestQuestionDialogMultilineRows(t *testing.T) {
237+ d := newQuestionDialog()
238+ r := d.Enqueue([]tools.Question{{Header: "H\nheader", Question: "Q\nquestion", Options: []tools.QuestionOption{{Label: "A\nlabel", Description: "D\ndescription"}}}})
239+ check := func() {
240+ t.Helper()
241+ for _, row := range d.Render(tui.Dark, 12) {
242+ if strings.Contains(row, "\n") {
243+ t.Fatalf("embedded newline: %q", row)
244+ }
245+ }
246+ }
247+ check()
248+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
249+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
250+ d.HandleKey(tui.Key{Kind: tui.KeyPaste, Paste: "one\r\ntwo\nthree\rfour"})
251+ if r.states[0].text != "one two three four" {
252+ t.Fatalf("paste = %q", r.states[0].text)
253+ }
254+ d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\n'})
255+ check()
256+ // Rendering also tolerates multiline state independently of input normalization.
257+ r.states[0].text = "typed\ntext"
258+ check()
259+}
260+
261+func TestQuestionDialogClipboardAndConfirmFocus(t *testing.T) {
262+ i := newConfirmationInputInteractive()
263+ r := i.questionDialog.Enqueue([]tools.Question{{Question: "Free text"}})
264+ if !i.confirmChildActive() {
265+ t.Fatal("question not recognized as clipboard child")
266+ }
267+ key, ok, err := resolveClipboardText(tui.Key{Kind: tui.KeyPasteClipboard}, func() (string, bool, error) { return "one\ntwo", true, nil })
268+ if err != nil || !ok || key.Kind != tui.KeyPaste {
269+ t.Fatalf("resolve: %+v %v", key, err)
270+ }
271+ i.handleKey(context.Background(), key)
272+ if r.states[0].text != "one two" || !i.ed.IsEmpty() {
273+ t.Fatal("paste not delivered to question input")
274+ }
275+ confirm := enqueueConfirmation(i)
276+ i.confirmDialog.Blur()
277+ i.restoreConfirmFocus()
278+ if i.confirmDialog.Focused() {
279+ t.Fatal("question stole focus restoration")
280+ }
281+ i.confirmDialog.Focus()
282+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
283+ if !(<-confirm).Allow {
284+ t.Fatal("confirmation focus broken")
285+ }
286+}
287+
288+func TestQuestionReadyAlertWhileBusy(t *testing.T) {
289+ for _, enabled := range []bool{true, false} {
290+ term := &alertTestTerminal{}
291+ i := &Interactive{cfg: InteractiveConfig{Terminal: term, TerminalAlertsEnabled: &enabled}, busy: true, questionDialog: newQuestionDialog(), dirty: make(chan struct{}, 1)}
292+ ctx, cancel := context.WithCancel(context.Background())
293+ done := make(chan error, 1)
294+ go func() { _, err := i.AskQuestions(ctx, []tools.Question{{Question: "Q"}}); done <- err }()
295+ <-i.dirty // Alert completes before invalidation; no redraw or timer needed.
296+ want := ""
297+ if enabled {
298+ want = "\a"
299+ }
300+ if got := term.String(); got != want {
301+ t.Errorf("busy alert = %q, want %q", got, want)
302+ }
303+ if i.pendingAlert != nil {
304+ t.Error("question alert deferred")
305+ }
306+ cancel()
307+ if err := <-done; !errors.Is(err, context.Canceled) {
308+ t.Fatal(err)
309+ }
310+ }
311+}

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 1

Tags 476

↑↓ moveenter openesc close