fix(modes): keep question dialog focus visible and answers consistent
3 files · +186 -7
M packages/agent/modes/interactive_input_routing.go +1 -0
| @@ -57,6 +57,7 @@ func (i *Interactive) confirmChildActive() bool { | |||
| 57 | 57 | func (i *Interactive) confirmChildActiveLocked() bool { | |
| 58 | 58 | return len(i.helpBlock) > 0 || | |
| 59 | 59 | len(i.extNotes) > 0 || | |
| 60 | + | i.questionDialog.Active() || | |
| 60 | 61 | i.dialog.Active() || | |
| 61 | 62 | i.modelDialog.Active() || | |
| 62 | 63 | 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 ( | |||
| 6 | 6 | "strings" | |
| 7 | 7 | "sync" | |
| 8 | 8 | ||
| 9 | + | "github.com/bnema/zut/packages/agent/extproto" | |
| 9 | 10 | "github.com/bnema/zut/packages/agent/tools" | |
| 10 | 11 | "github.com/bnema/zut/packages/tui" | |
| 11 | 12 | ) | |
| @@ -124,11 +125,16 @@ func (d *questionDialog) HandleKey(k tui.Key) { | |||
| 124 | 125 | runes := []rune(s.text) | |
| 125 | 126 | if len(runes) > 0 { | |
| 126 | 127 | s.text = string(runes[:len(runes)-1]) | |
| 128 | + | s.answered = false | |
| 127 | 129 | } | |
| 128 | 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 | 133 | 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 | + | } | |
| 132 | 138 | } | |
| 133 | 139 | return | |
| 134 | 140 | } | |
| @@ -150,6 +156,7 @@ func (d *questionDialog) HandleKey(k tui.Key) { | |||
| 150 | 156 | s.cursor = n | |
| 151 | 157 | if q.MultiSelect { | |
| 152 | 158 | s.checked[n] = !s.checked[n] | |
| 159 | + | s.answered = false | |
| 153 | 160 | return | |
| 154 | 161 | } | |
| 155 | 162 | k.Kind = tui.KeyEnter | |
| @@ -157,6 +164,7 @@ func (d *questionDialog) HandleKey(k tui.Key) { | |||
| 157 | 164 | } | |
| 158 | 165 | if k.Kind == tui.KeyRune && k.Rune == ' ' && q.MultiSelect && s.cursor < len(q.Options) { | |
| 159 | 166 | s.checked[s.cursor] = !s.checked[s.cursor] | |
| 167 | + | s.answered = false | |
| 160 | 168 | return | |
| 161 | 169 | } | |
| 162 | 170 | if k.Kind != tui.KeyEnter { | |
| @@ -198,12 +206,24 @@ func (d *questionDialog) Render(th tui.Theme, width int) []string { | |||
| 198 | 206 | title += " · " + q.Header | |
| 199 | 207 | } | |
| 200 | 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 | 222 | add(q.Question) | |
| 203 | 223 | lines = append(lines, "") | |
| 204 | 224 | footer := th.FGColor(th.Muted, "↑/↓ j/k move · 1–9 pick · Space toggle · Enter confirm · ←/Shift+Tab back · Tab next · Esc decline") | |
| 205 | 225 | if s.editing { | |
| 206 | - | add("> " + s.text + "▏") | |
| 226 | + | addRows("> "+s.text+"▏", true) | |
| 207 | 227 | footer = th.FGColor(th.Muted, "Enter submit · Esc back/cancel · Shift+Tab previous") | |
| 208 | 228 | } else { | |
| 209 | 229 | for n := 0; n <= len(q.Options); n++ { | |
| @@ -225,7 +245,7 @@ func (d *questionDialog) Render(th tui.Theme, width int) []string { | |||
| 225 | 245 | if n == s.cursor { | |
| 226 | 246 | prefix = "› " | |
| 227 | 247 | } | |
| 228 | - | add(prefix + label) | |
| 248 | + | addRows(prefix+label, n == s.cursor) | |
| 229 | 249 | } | |
| 230 | 250 | } | |
| 231 | 251 | if r.hint != "" { | |
| @@ -237,7 +257,9 @@ func (d *questionDialog) Render(th tui.Theme, width int) []string { | |||
| 237 | 257 | // Frame helpers may retain their minimum decoration width. Wrap these too. | |
| 238 | 258 | var out []string | |
| 239 | 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 | 264 | return out | |
| 243 | 265 | } | |
| @@ -248,7 +270,7 @@ func (i *Interactive) AskQuestions(ctx context.Context, qs []tools.Question) ([] | |||
| 248 | 270 | return nil, err | |
| 249 | 271 | } | |
| 250 | 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 | 274 | i.invalidate() | |
| 253 | 275 | defer func() { i.questionDialog.Remove(r); i.invalidate() }() | |
| 254 | 276 | 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.Paste | 134 | + | s.text += strings.NewReplacer("\r\n", " ", "\r", " ", "\n", " ").Replace(k.Paste) |
| 135 | + | if k.Paste != "" { | |||
| 136 | + | s.answered = false | |||
| 137 | + | } | |||
| 132 | } | 138 | } | ||
| 133 | return | 139 | return | ||
| 134 | } | 140 | } | ||
| @@ -150,6 +156,7 @@ func (d *questionDialog) HandleKey(k tui.Key) { | |||||
| 150 | s.cursor = n | 156 | 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 | return | 160 | return | ||
| 154 | } | 161 | } | ||
| 155 | k.Kind = tui.KeyEnter | 162 | 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 | return | 168 | 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.Header | 206 | 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 []string | 258 | 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 out | 264 | return out | ||
| 243 | } | 265 | } | ||
| @@ -248,7 +270,7 @@ func (i *Interactive) AskQuestions(ctx context.Context, qs []tools.Question) ([] | |||||
| 248 | return nil, err | 270 | 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) { | |||
| 153 | 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 | + | } | |
| @@ -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 | + | } | |||