From e3e1828ad94798dc8f249d74b4737436849082de Mon Sep 17 00:00:00 2001 From: Stener1 Date: Mon, 19 Oct 2026 16:39:25 +0000 Subject: [PATCH 1/4] fix(box): answers from the chat reach Claude Code's form on a short pane, with long words An agent nobody has open sits at tmux's 80x24. A form of questions with lists in them is taller than that: its tabs and question scroll off, and what scrolls off is an old frame, so the answer stopped before the first key. The pane is made tall enough while it is answered, then put back with its own window-size setting. The form is also read as Claude Code 2.1.3 draws it: a question of several lines behind a bar, a question's own numbered lines kept apart from the options, and long own words read whole across their wrapped lines. A multiple question form must reach its review, never counted done without it. Every answer that doesn't go is logged with why and the form it stopped at. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VT2hKzCE1WfLU9uk6jhKd6 --- internal/box/answer.go | 180 ++++++++++--- internal/box/answer_test.go | 251 +++++++++++++++++- .../testdata/answer/own-words-wrapped-80.txt | 25 ++ .../box/testdata/answer/question-list-80.txt | 21 ++ .../testdata/answer/question-list-short.txt | 14 + .../box/testdata/answer/review-long-80.txt | 28 ++ 6 files changed, 473 insertions(+), 46 deletions(-) create mode 100644 internal/box/testdata/answer/own-words-wrapped-80.txt create mode 100644 internal/box/testdata/answer/question-list-80.txt create mode 100644 internal/box/testdata/answer/question-list-short.txt create mode 100644 internal/box/testdata/answer/review-long-80.txt diff --git a/internal/box/answer.go b/internal/box/answer.go index 3389e9fe..4b4d8cbb 100644 --- a/internal/box/answer.go +++ b/internal/box/answer.go @@ -3,6 +3,7 @@ package box import ( "context" "fmt" + "log" "net/http" "regexp" "strconv" @@ -62,23 +63,29 @@ func (b *Box) answerSession(w http.ResponseWriter, r *http.Request) error { if err != nil { return err } + shown, err := b.answer(r.Context(), sess, req) + if err != nil { + // Every answer that didn't go says why in the box's log. + log.Printf("answer %s: %v", sess.Name, err) + return err + } + log.Printf("answer %s: answered %d questions", sess.Name, len(shown)) + writeJSON(w, AnswerResult{Answered: shown}) + return nil +} + +func (b *Box) answer(ctx context.Context, sess Session, req AnswerRequest) ([]string, error) { if b.Turns != nil { if st, ok := b.Turns.State(sess.Name); ok && st.State != "waiting" { - return httpError{http.StatusConflict, "Claude isn't waiting for an answer"} + return nil, httpError{http.StatusConflict, "Claude isn't waiting for an answer"} } } mu, _ := answering.LoadOrStore(sess.Name, &sync.Mutex{}) if !mu.(*sync.Mutex).TryLock() { - return httpError{http.StatusConflict, "already answering Claude's questions"} + return nil, httpError{http.StatusConflict, "already answering Claude's questions"} } defer mu.(*sync.Mutex).Unlock() - - shown, err := b.driverFor(sess).Answer(r.Context(), sess, req) - if err != nil { - return err - } - writeJSON(w, AnswerResult{Answered: shown}) - return nil + return b.driverFor(sess).Answer(ctx, sess, req) } // checkAnswers says the answers fit the questions, and returns each as @@ -127,6 +134,9 @@ func checkAnswers(qs []transcript.Question, as []QuestionAnswer) ([]string, erro type keyTerm interface { keys(k ...string) error screen() string + // fit makes the screen tall enough to show a whole form, and gives + // back what puts it as it was. + fit() (restore func()) } type tmuxTerm struct { @@ -157,6 +167,64 @@ func (t tmuxTerm) screen() string { return string(out) } +// formRows is a screen tall enough for any form: four questions' +// review, each with a long answer, at 80 columns. +const formRows = 200 + +// fit grows a short pane while it is answered. A form taller than its +// screen scrolls its tabs and question off the top, where the screen +// can't be read: what scrolls off is an old frame, not the one drawn +// after it. An agent nobody has open sits at tmux's 80x24, +// which a question with a list in it outgrows. +func (t tmuxTerm) fit() func() { + ctx := context.Background() + out, err := t.s.tmux(ctx, "display-message", "-p", "-t", t.target, "#{window_width} #{window_height}") + if err != nil { + return func() {} + } + var w, h int + if _, err := fmt.Sscan(string(out), &w, &h); err != nil || h >= formRows { + return func() {} + } + // resize-window fixes the window's size (window-size manual): its own + // setting, if it had one, goes back after, else it inherits again. + own, _ := t.s.tmux(ctx, "show-options", "-w", "-q", "-v", "-t", t.target, "window-size") + if _, err := t.s.tmux(ctx, "resize-window", "-t", t.target, "-y", strconv.Itoa(formRows)); err != nil { + return func() {} + } + policy := []string{"set-option", "-w", "-u", "-t", t.target, "window-size"} + if v := strings.TrimSpace(string(own)); v != "" { + policy = []string{"set-option", "-w", "-t", t.target, "window-size", v} + } + return func() { + t.s.tmux(ctx, append([]string{"resize-window", "-t", t.target, "-x", strconv.Itoa(w), "-y", strconv.Itoa(h), ";"}, policy...)...) + } +} + +// formTail is what the box's log keeps of a screen where an answer +// stopped: the form, from its tabs down, or the foot of a screen without +// one. Never the conversation above it. The form holds the person's own +// words, as the agent's record does on the same box: without what the +// screen showed, the next failure can't be told apart from this one. +func formTail(sc string) string { + lines := strings.Split(strings.TrimRight(sc, " \n\t"), "\n") + from := max(len(lines)-8, 0) + for i := len(lines) - 1; i >= 0; i-- { + l := strings.TrimSpace(lines[i]) + if (strings.HasPrefix(l, "←") || strings.HasPrefix(l, "☐") || strings.HasPrefix(l, "☒")) && strings.ContainsAny(l, "☐☒") { + from = i + break + } + } + var out []string + for _, l := range lines[from:] { + if l = strings.TrimRight(l, " \t"); l != "" { + out = append(out, " | "+l) + } + } + return strings.Join(out, "\n") +} + // answerStep is how long one key's effect may take to show. var answerStep = 2500 * time.Millisecond @@ -166,8 +234,17 @@ func stuck(format string, args ...any) error { } // driveAnswers fills in Claude Code's question form from the top, one -// question at a time, checking the screen after each key. -func driveAnswers(ctx context.Context, t keyTerm, qs []transcript.Question, as []QuestionAnswer) error { +// question at a time, checking the screen after each key. Where it stops, +// the box's log has the screen it stopped at. +func driveAnswers(ctx context.Context, t keyTerm, qs []transcript.Question, as []QuestionAnswer) (err error) { + restore := t.fit() + defer func() { + if err != nil { + // answerSession logs why. + log.Printf("answer: Claude's form when it stopped:\n%s", formTail(t.screen())) + } + restore() + }() until := func(ok func(formView) bool) (formView, bool) { end := time.Now().Add(answerStep) for { @@ -228,23 +305,27 @@ func driveAnswers(ctx context.Context, t keyTerm, qs []transcript.Question, as [ } r := f.row(row) switch { - case flatText(r.text) == words && (!multi || r.checked): + case r.words() == words && (!multi || r.checked): return nil case !ownWordsEmpty(r.text): - return stuck("Claude's own-words answer already holds %q", r.text) + return stuck("Claude's own-words answer already holds %q", clip(r.words(), 60)) } if err := press("-l", words); err != nil { return err } - if _, ok := until(func(g formView) bool { + // Long words wrap onto the lines under the row: read whole, they + // are exactly what was typed. + if g, ok := until(func(g formView) bool { r := g.row(row) - return g.ok && strings.HasPrefix(words, flatText(r.text)) && len(r.text) >= min(len(words), 8) && (!multi || r.checked) + return g.ok && r.words() == words && (!multi || r.checked) }); !ok { - return stuck("Claude's form didn't take your words") + return stuck("Claude's form didn't take your words: it shows %q", clip(g.row(row).words(), 60)) } return nil } + // More than one question, or several picks, end in a review. + reviews := len(qs) > 1 || qs[0].Multi // The form opens on the first question; one the person moved on from // goes back to it. f, ok := until(func(f formView) bool { return f.ok && (f.review || questionIndex(qs, f.question) >= 0) }) @@ -328,30 +409,35 @@ func driveAnswers(ctx context.Context, t keyTerm, qs []transcript.Question, as [ return err } last := i == len(qs)-1 - if _, ok := until(func(f formView) bool { - if last { - return !f.ok || f.review + var ok bool + if f, ok = until(func(f formView) bool { + switch { + case !last: + return f.ok && !f.review && questionIndex(qs, f.question) == i+1 + case reviews: + return f.ok && f.review } - return f.ok && !f.review && questionIndex(qs, f.question) == i+1 + return !f.ok }); !ok { + if last && reviews { + return stuck("Claude's form didn't show its review after question %d", i+1) + } return stuck("Claude didn't take the answer to question %d", i+1) } } + if !reviews { + return nil + } // More than one question, or several picks: Claude Code shows them all // before they go. Submit only what it shows as wanted. - f = readForm(t.screen()) - if !f.ok { - return nil - } shown, _ := checkAnswers(qs, as) for i, s := range shown { if !strings.Contains(f.reviewText, "→ "+flatText(s)) { return stuck("Claude's review doesn't show %q for question %d", s, i+1) } } - f, err := moveTo(1) - if err != nil { + if f, err = moveTo(1); err != nil { return err } if r := f.row(1); r.text != "Submit answers" { @@ -381,12 +467,20 @@ type formView struct { } type formRow struct { - num int // 0 for the Next row - text string + num int // 0 for the Next row + text string + // more is the lines under it: an option's description, or the rest + // of long words of the person's own, wrapped. + more []string box bool // a checkbox: "[ ]" or "[✔]" checked bool } +// words is the person's own words in the row, on one line. +func (r formRow) words() string { + return flatText(r.text + " " + strings.Join(r.more, " ")) +} + func (f formView) row(n int) formRow { for _, r := range f.rows { if r.num == n { @@ -399,11 +493,15 @@ func (f formView) row(n int) formRow { func (f formView) has(n int) bool { return f.row(n).num == n } var ( - formRule = regexp.MustCompile(`^\s*[─━]{8,}\s*$`) - formRowRe = regexp.MustCompile(`^\s*(❯)?[\s\x{a0}]*(\d{1,2})\.[\s\x{a0}]+(.*?)\s*$`) - formNext = regexp.MustCompile(`^\s*(❯)?[\s\x{a0}]*(Next|Submit)\s*$`) - formBox = regexp.MustCompile(`^\[([^\]])\]\s*(.*)$`) - formEnd = regexp.MustCompile(`^(❯\s*)?\d\.\s+Cancel$`) + formRule = regexp.MustCompile(`^\s*[─━]{8,}\s*$`) + // A row is "❯ 1." or " 1.": a question's own "1." lines start at the + // edge, or after its "│". + formRowRe = regexp.MustCompile(`^(?:(❯)|[\s\x{a0}])[\s\x{a0}](\d{1,2})\.[\s\x{a0}]+(.*?)\s*$`) + // A question of several lines is drawn behind a bar (Claude Code 2.1.3). + formGutter = regexp.MustCompile(`^\s*│\s?`) + formNext = regexp.MustCompile(`^\s*(❯)?[\s\x{a0}]*(Next|Submit)\s*$`) + formBox = regexp.MustCompile(`^\[([^\]])\]\s*(.*)$`) + formEnd = regexp.MustCompile(`^(❯\s*)?\d\.\s+Cancel$`) ) // readForm reads the form at the foot of a screen. It sits under its tab @@ -431,7 +529,7 @@ func readForm(sc string) formView { return f } tabs := -1 - for i := end; i >= 0 && i > end-80; i-- { + for i := end; i >= 0; i-- { l := strings.TrimSpace(lines[i]) if (strings.HasPrefix(l, "←") || strings.HasPrefix(l, "☐") || strings.HasPrefix(l, "☒")) && strings.ContainsAny(l, "☐☒") { tabs = i @@ -461,6 +559,11 @@ func readForm(sc string) formView { var words []string for _, l := range region { if formRule.MatchString(l) { + if len(f.rows) == 0 && !f.review { + // A rule before any option: these tabs are an older + // frame's, above something else. + return formView{cursor: -1} + } break // what follows ("Chat about this") isn't an answer } if m := formNext.FindStringSubmatch(l); m != nil && !f.review { @@ -472,8 +575,13 @@ func readForm(sc string) formView { } m := formRowRe.FindStringSubmatch(l) if m == nil { - if len(f.rows) == 0 && !f.review { - words = append(words, l) + switch { + case len(f.rows) > 0: + if r := &f.rows[len(f.rows)-1]; r.num > 0 && strings.TrimSpace(l) != "" { + r.more = append(r.more, strings.TrimSpace(l)) + } + case !f.review: + words = append(words, formGutter.ReplaceAllString(l, "")) } continue } diff --git a/internal/box/answer_test.go b/internal/box/answer_test.go index de16ae1e..46da1c0c 100644 --- a/internal/box/answer_test.go +++ b/internal/box/answer_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http" + "os" "strconv" "strings" "testing" @@ -17,8 +18,17 @@ import ( // (Left and Right between questions), Enter picks a single choice or // ticks a multiple one, Space ticks, typing goes into "Type something", // Next (Submit for the last) moves on, and a review lists the answers -// before they go. +// before they go. With a width, long words wrap as the real one wraps +// them (testdata/answer); with rows, only the foot of the form shows, as +// on a pane shorter than it. type fakeForm struct { + width, rows int + // fits counts how often the screen was made tall enough; noFit keeps + // it short. + fits int + noFit bool + // typed can change the words that arrive (a fault). + typed func(string) string qs []transcript.Question tab int // the question shown; len(qs) is the review cursor []int @@ -45,12 +55,26 @@ func newFakeForm(qs []transcript.Question) *fakeForm { func (f *fakeForm) reviews() bool { return len(f.qs) > 1 || f.qs[0].Multi } +func (f *fakeForm) fit() func() { + if f.noFit { + return func() {} + } + old := f.rows + f.rows = 0 + f.fits++ + return func() { f.rows = old } +} + func (f *fakeForm) keys(k ...string) error { if len(k) == 2 && k[0] == "-l" { f.keysLog = append(f.keysLog, "type:"+k[1]) q := f.qs[f.tab] if f.cursor[f.tab] == len(q.Options)+1 { - f.other[f.tab] += k[1] + words := k[1] + if f.typed != nil { + words = f.typed(words) + } + f.other[f.tab] += words if q.Multi { f.ticked[f.tab][len(q.Options)+1] = true } @@ -156,9 +180,44 @@ func (f *fakeForm) answer(i int) string { const fakeRule = "────────────────────────────────────────────────────────────────────────────────" +// wrap breaks words into lines of the form's width, the first after +// first and the rest after more, as Claude Code wraps them. +func (f *fakeForm) wrap(first, more, words string) []string { + if f.width == 0 { + return []string{first + words} + } + var out []string + line := first + for _, word := range strings.Fields(words) { + if line != first && line != more && len([]rune(line+" "+word)) > f.width { + out = append(out, line) + line = more + } + if line == first || line == more { + line += word + } else { + line += " " + word + } + } + return append(out, line) +} + +// screen is the frame as the pane shows it: a short one, its foot only. func (f *fakeForm) screen() string { + lines := strings.Split(strings.TrimSuffix(f.frame(), "\n"), "\n") + if f.rows > 0 && len(lines) > f.rows { + lines = lines[len(lines)-f.rows:] + } + return strings.Join(lines, "\n") + "\n" +} + +func (f *fakeForm) frame() string { var b strings.Builder - w := func(s string) { b.WriteString(s + "\n") } + w := func(lines ...string) { + for _, s := range lines { + b.WriteString(s + "\n") + } + } w("❯ Use the AskUserQuestion tool to ask me three questions at once: 1. one single-choice") w(" 2. one multi-select") w("") @@ -192,8 +251,15 @@ func (f *fakeForm) screen() string { if f.tab == len(f.qs) { w("Review your answers") for i, q := range f.qs { - w(" ● " + q.Question + " ") - w(" → " + f.answer(i) + " ") + if lines := strings.Split(q.Question, "\n"); len(lines) > 1 { + w(" │ ● " + lines[0]) + for _, l := range lines[1:] { + w(f.wrap(" │ ", " │ ", l)...) + } + } else { + w(" ● " + q.Question + " ") + } + w(f.wrap(" → ", " ", f.answer(i))...) } w("Ready to submit your answers?") mark := func(n int) string { @@ -209,13 +275,19 @@ func (f *fakeForm) screen() string { return b.String() } q := f.qs[f.tab] - // A long question wraps. + // A long question wraps; one of several lines is drawn behind a bar. words := q.Question - if len(words) > 50 { + switch { + case strings.Contains(words, "\n"): + for _, l := range strings.Split(words, "\n") { + w(f.wrap("│ ", "│ ", l)...) + } + w("") + case len(words) > 50: cut := strings.LastIndex(words[:50], " ") w(words[:cut]) w(words[cut+1:]) - } else { + default: w(words) } mark := func(n int) string { @@ -246,7 +318,7 @@ func (f *fakeForm) screen() string { if f.ticked[f.tab][n+1] { box = "[✔]" } - w(mark(n+1) + strconv.Itoa(n+1) + ". " + box + " " + text + " ") + w(f.wrap(mark(n+1)+strconv.Itoa(n+1)+". "+box+" ", " ", text)...) label := "Next" if f.tab == len(f.qs)-1 { label = "Submit" @@ -257,7 +329,7 @@ func (f *fakeForm) screen() string { if f.other[f.tab] != "" { text = f.other[f.tab] } - w(mark(n+1) + strconv.Itoa(n+1) + ". " + text) + w(f.wrap(mark(n+1)+strconv.Itoa(n+1)+". ", " ", text)...) } w(fakeRule) w(" " + strconv.Itoa(n+2) + ". Chat about this") @@ -353,6 +425,14 @@ func TestDriveStopsOnSurprise(t *testing.T) { } return key }, + // The review is drawn, but its top is out of sight: nothing + // says what would go, so nothing goes. + "review out of sight": func(f *fakeForm, key string) string { + if f.tab == len(f.qs)-1 && key == "Enter" { + f.rows = 6 + } + return key + }, // A different question comes up. "other question": func(f *fakeForm, key string) string { if f.tab == 1 && key == "Enter" && f.cursor[1] == 0 { @@ -374,6 +454,13 @@ func TestDriveStopsOnSurprise(t *testing.T) { t.Errorf("%s: submitted anyway", name) } } + // Words that arrive short (a key lost) stop it at that question. + f0 := newFakeForm(threeQs) + f0.typed = func(s string) string { return s[:len(s)-2] } + err := driveAnswers(context.Background(), f0, threeQs, []QuestionAnswer{{Picks: []string{"Green"}}, {Picks: []string{"Ham"}}, {Other: "Captain Whiskers"}}) + if err == nil || !strings.Contains(err.Error(), "didn't take your words") || f0.sent { + t.Errorf("lost keys: %v, sent %v", err, f0.sent) + } // Not the form at all. f := &plainScreen{s: "❯ \n" + fakeRule + "\n ⏵⏵ auto mode on"} if err := driveAnswers(context.Background(), f, threeQs, as); err == nil || !strings.Contains(err.Error(), "doesn't show its questions") { @@ -391,6 +478,7 @@ type plainScreen struct { func (p *plainScreen) keys(k ...string) error { p.sent = append(p.sent, k...); return nil } func (p *plainScreen) screen() string { return p.s } +func (p *plainScreen) fit() func() { return func() {} } func TestCheckAnswers(t *testing.T) { ok := []QuestionAnswer{{Picks: []string{"Green"}}, {Picks: []string{"Ham"}}, {Other: "Rex"}} @@ -531,3 +619,146 @@ func TestReadForm(t *testing.T) { t.Fatalf("permission read as a form: %+v", f) } } + +// Questions with lists in them, as agents ask, and long answers: what an +// 80x24 pane can't show whole. +var listQs = []transcript.Question{ + {Question: "How should acme roll out the new checkout?\n1. The old flow stays behind a flag for a week.\n2. Support wants a heads-up before any change customers can see, and release notes go out on Mondays.\n3. Mobile ships on its own schedule.", Header: "Rollout", Options: []transcript.Option{{Label: "Flag first (Recommended)", Description: "Ship behind the flag, turn it on for staff, then everyone."}, {Label: "All at once", Description: "Turn it on for everyone on release day."}, {Label: "Wait", Description: "Hold until mobile is ready too."}}}, + {Question: "Who owns the rollout?\n1. Alex wrote most of the change.\n2. Sam is on call that week.", Header: "Owner", Options: []transcript.Option{{Label: "Alex", Description: "The author."}, {Label: "Sam", Description: "The on-call engineer."}}}, + {Question: "Anything else the team should know before starting?", Header: "Notes", Options: []transcript.Option{{Label: "Nothing", Description: "No further notes."}, {Label: "Later", Description: "More notes will follow."}}}, +} + +const ( + longWords1 = "Flag first, but keep the flag on for staff only until the support team has had a full Monday with the new flow. If the error rate on payment confirmation goes above half a percent at any point, turn the flag off and tell the channel before anything else." + longWords3 = "The staging database was restored from last week's backup, so some fixtures may be stale. Release notes are drafted in the shared folder; please add a line about the flag and who to ask." +) + +// Long words wrap, and the form is taller than the pane: it is made tall +// enough while it is answered, every answer goes in whole, and the pane +// is put back. +func TestDriveLongWordsOnAShortPane(t *testing.T) { + fastSteps(t) + f := newFakeForm(listQs) + f.width, f.rows = 80, 24 + as := []QuestionAnswer{{Other: longWords1}, {Picks: []string{"Sam"}}, {Other: longWords3}} + if _, err := checkAnswers(listQs, as); err != nil { + t.Fatal(err) + } + if err := driveAnswers(context.Background(), f, listQs, as); err != nil { + t.Fatalf("drive: %v\nkeys %v\n%s", err, f.keysLog, f.screen()) + } + if !f.sent || f.answer(0) != longWords1 || f.answer(1) != "Sam" || f.answer(2) != longWords3 { + t.Fatalf("sent %v: %q %q %q", f.sent, f.answer(0), f.answer(1), f.answer(2)) + } + if f.fits != 1 || f.rows != 24 { + t.Errorf("fits %d, rows put back to %d", f.fits, f.rows) + } +} + +// Kept short, the form's top is out of sight: nothing is pressed. +func TestDriveNeedsTheWholeForm(t *testing.T) { + fastSteps(t) + f := newFakeForm(listQs) + f.width, f.rows, f.noFit = 80, 14, true + err := driveAnswers(context.Background(), f, listQs, []QuestionAnswer{{Other: longWords1}, {Picks: []string{"Sam"}}, {Other: longWords3}}) + if err == nil || !strings.Contains(err.Error(), "doesn't show its questions") || len(f.keysLog) != 0 { + t.Fatalf("err %v, keys %v", err, f.keysLog) + } +} + +func readScreen(t *testing.T, name string) string { + t.Helper() + b, err := os.ReadFile("testdata/answer/" + name) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +// readForm on Claude Code 2.1.303's screens at 80 columns, captured with +// the questions above (testdata/answer). +func TestReadFormRealScreens(t *testing.T) { + // A question of several lines, behind its bar, with a list in it. + f := readForm(readScreen(t, "question-list-80.txt")) + if !f.ok || f.review || questionIndex(listQs, f.question) != 0 || f.cursor != 1 || len(f.rows) != 4 { + t.Fatalf("question: %+v", f) + } + if f.question != flatText(listQs[0].Question) { + t.Errorf("question words = %q", f.question) + } + if r := f.row(1); r.text != "Flag first (Recommended)" || !ownWordsEmpty(f.row(4).text) { + t.Errorf("rows = %+v", f.rows) + } + // Drawn without the bar (earlier versions), its "1." lines are still + // the question's, not options. + bare := strings.ReplaceAll(readScreen(t, "question-list-80.txt"), "│ ", "") + if f := readForm(bare); !f.ok || questionIndex(listQs, f.question) != 0 || len(f.rows) != 4 || f.row(1).text != "Flag first (Recommended)" { + t.Fatalf("bare: %+v", f) + } + // The foot of it, as a pane too short for it shows: not readable. + if f := readForm(readScreen(t, "question-list-short.txt")); f.ok { + t.Fatalf("short: %+v", f) + } + // Long words of the person's own, wrapped under their row. + f = readForm(readScreen(t, "own-words-wrapped-80.txt")) + if !f.ok || f.cursor != 4 || f.row(4).words() != longWords1 { + t.Fatalf("own words: %q\n%+v", f.row(4).words(), f) + } + // The review, every long answer wrapped. + f = readForm(readScreen(t, "review-long-80.txt")) + if !f.ok || !f.review || f.cursor != 1 || f.row(1).text != "Submit answers" { + t.Fatalf("review: %+v", f) + } + for _, a := range []string{longWords1, "Sam", longWords3} { + if !strings.Contains(f.reviewText, "→ "+a) { + t.Errorf("review lacks %q:\n%s", a, f.reviewText) + } + } +} + +// fit makes a short pane tall enough, then puts back its size and its own +// window-size setting, or none. +func TestFitPutsThePaneBack(t *testing.T) { + s := testSessions(t) + ctx := context.Background() + if out, err := s.tmux(ctx, "new-session", "-d", "-s", "fit", "-x", "80", "-y", "24", "sh"); err != nil { + t.Fatalf("%v: %s", err, out) + } + term := tmuxTerm{s: s, target: "=fit:"} + size := func() string { + out, _ := s.tmux(ctx, "display-message", "-p", "-t", term.target, "#{window_width}x#{window_height}") + return strings.TrimSpace(string(out)) + } + own := func() string { + out, _ := s.tmux(ctx, "show-options", "-w", "-q", "-v", "-t", term.target, "window-size") + return strings.TrimSpace(string(out)) + } + for _, policy := range []string{"", "smallest"} { + if policy != "" { + s.tmux(ctx, "set-option", "-w", "-t", term.target, "window-size", policy) + } + restore := term.fit() + if got := size(); got != "80x"+strconv.Itoa(formRows) { + t.Fatalf("fitted: %s", got) + } + restore() + if got := size(); got != "80x24" { + t.Errorf("%q: put back to %s", policy, got) + } + if got := own(); got != policy { + t.Errorf("window-size %q put back as %q", policy, got) + } + } +} + +// The log keeps the form, not the conversation above it. +func TestFormTail(t *testing.T) { + sc := "secret: hunter2\n" + readScreen(t, "question-list-80.txt") + got := formTail(sc) + if strings.Contains(got, "hunter2") || strings.Contains(got, "acme checkout rollout with") || !strings.Contains(got, "☐ Rollout") || !strings.Contains(got, "4. Type something.") { + t.Fatalf("formTail:\n%s", got) + } + if got := formTail("a\nb\nc\nd\ne\nf\ng\nh\ni\nj"); strings.Contains(got, "| b") || !strings.Contains(got, "| j") { + t.Errorf("no form: %s", got) + } +} diff --git a/internal/box/testdata/answer/own-words-wrapped-80.txt b/internal/box/testdata/answer/own-words-wrapped-80.txt new file mode 100644 index 00000000..3b35b90e --- /dev/null +++ b/internal/box/testdata/answer/own-words-wrapped-80.txt @@ -0,0 +1,25 @@ +❯ Ask me about the acme checkout rollout with AskUserQuestion. +──────────────────────────────────────────────────────────────────────────────── +← ☐ Rollout ☐ Owner ☐ Notes ✔ Submit → + +│ How should acme roll out the new checkout? +│ 1. The old flow stays behind a flag for a week. +│ 2. Support wants a heads-up before any change customers can see, and release +│ notes go out on Mondays. +│ 3. Mobile ships on its own schedule. + + 1. Flag first (Recommended) + Ship behind the flag, turn it on for staff, then everyone. + 2. All at once + Turn it on for everyone on release day. + 3. Wait + Hold until mobile is ready too. +❯ 4. Flag first, but keep the flag on for staff only until the support team has + had a full Monday with the new flow. If the error rate on payment + confirmation goes above half a percent at any point, turn the flag off and + tell the channel before anything else. +──────────────────────────────────────────────────────────────────────────────── + 5. Chat about this + +Enter to select · Tab/Arrow keys to navigate · ctrl+g to edit in Vim · Esc to +cancel diff --git a/internal/box/testdata/answer/question-list-80.txt b/internal/box/testdata/answer/question-list-80.txt new file mode 100644 index 00000000..1168f713 --- /dev/null +++ b/internal/box/testdata/answer/question-list-80.txt @@ -0,0 +1,21 @@ +❯ Ask me about the acme checkout rollout with AskUserQuestion. +──────────────────────────────────────────────────────────────────────────────── +← ☐ Rollout ☐ Owner ☐ Notes ✔ Submit → + +│ How should acme roll out the new checkout? +│ 1. The old flow stays behind a flag for a week. +│ 2. Support wants a heads-up before any change customers can see, and release +│ notes go out on Mondays. +│ 3. Mobile ships on its own schedule. + +❯ 1. Flag first (Recommended) + Ship behind the flag, turn it on for staff, then everyone. + 2. All at once + Turn it on for everyone on release day. + 3. Wait + Hold until mobile is ready too. + 4. Type something. +──────────────────────────────────────────────────────────────────────────────── + 5. Chat about this + +Enter to select · Tab/Arrow keys to navigate · Esc to cancel diff --git a/internal/box/testdata/answer/question-list-short.txt b/internal/box/testdata/answer/question-list-short.txt new file mode 100644 index 00000000..15ed09b9 --- /dev/null +++ b/internal/box/testdata/answer/question-list-short.txt @@ -0,0 +1,14 @@ +│ notes go out on Mondays. +│ 3. Mobile ships on its own schedule. + +❯ 1. Flag first (Recommended) + Ship behind the flag, turn it on for staff, then everyone. + 2. All at once + Turn it on for everyone on release day. + 3. Wait + Hold until mobile is ready too. + 4. Type something. +──────────────────────────────────────────────────────────────────────────────── + 5. Chat about this + +Enter to select · Tab/Arrow keys to navigate · Esc to cancel diff --git a/internal/box/testdata/answer/review-long-80.txt b/internal/box/testdata/answer/review-long-80.txt new file mode 100644 index 00000000..51460a9a --- /dev/null +++ b/internal/box/testdata/answer/review-long-80.txt @@ -0,0 +1,28 @@ +❯ Ask me about the acme checkout rollout with AskUserQuestion. +──────────────────────────────────────────────────────────────────────────────── +← ☒ Rollout ☒ Owner ☒ Notes ✔ Submit → + +Review your answers + + │ ● How should acme roll out the new checkout? + │ 1. The old flow stays behind a flag for a week. + │ 2. Support wants a heads-up before any change customers can see, and + │ release notes go out on Mondays. + │ 3. Mobile ships on its own schedule. + → Flag first, but keep the flag on for staff only until the support team has + had a full Monday with the new flow. If the error rate on payment + confirmation goes above half a percent at any point, turn the flag off and + tell the channel before anything else. + │ ● Who owns the rollout? + │ 1. Alex wrote most of the change. + │ 2. Sam is on call that week. + → Sam + ● Anything else the team should know before starting? + → The staging database was restored from last week's backup, so some fixtures + may be stale. Release notes are drafted in the shared folder; please add a + line about the flag and who to ask. + +Ready to submit your answers? + +❯ 1. Submit answers + 2. Cancel From 90b88cd7c417ed94d92fa271829d3d896a313f3f Mon Sep 17 00:00:00 2001 From: Stener1 Date: Mon, 19 Oct 2026 16:39:25 +0000 Subject: [PATCH 2/4] fix(app): answers that don't reach the agent stay in the card, with Retry and Copy answers When the box can't fill in the agent's form, the card no longer folds into the terminal: it keeps every answer, says why, offers Retry and Copy answers (one line each under its question's name), and the agent's own screen opens under it to finish there. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VT2hKzCE1WfLU9uk6jhKd6 --- app/e2e/chat.spec.ts | 39 +++++++++++++ .../conversation/conversation-pane.tsx | 6 +- .../components/conversation/question-form.tsx | 56 +++++++++++++------ app/src/lib/mock-conversation.ts | 8 +++ app/src/lib/questions.test.ts | 20 +++++++ app/src/lib/questions.ts | 7 +++ 6 files changed, 118 insertions(+), 18 deletions(-) create mode 100644 app/src/lib/questions.test.ts diff --git a/app/e2e/chat.spec.ts b/app/e2e/chat.spec.ts index bd4bc6e8..86195d40 100644 --- a/app/e2e/chat.spec.ts +++ b/app/e2e/chat.spec.ts @@ -186,6 +186,45 @@ test("a form of questions steps through to a review and submits", async ({ app } await expect(app.chat.getByText("I'll plan it for Next minor")).toBeVisible(); }); +test("answers the box couldn't deliver stay in the form, to retry or copy", async ({ app }) => { + mockOnly("answers a question on a box"); + await app.open({ params: { view: "conversation", scene: "answer-stuck" } }); + await app.openWorktree("gpu/shop"); + const form = app.chat.getByTestId("question-form"); + const next = form.getByRole("button", { name: /^(Next|Review|Submit answers)$/ }).last(); + const words = "Ship it Thursday only if the payment provider's sandbox is green by Wednesday noon; otherwise hold it for QA and tell support before anything else."; + await form.getByRole("radio", { name: "Something else" }).click(); + await form.getByRole("textbox", { name: "Your own answer" }).fill(words); + await next.click(); + await form.getByRole("checkbox", { name: /Unit tests/ }).click(); + await next.click(); + await form.getByRole("radio", { name: /bailey/ }).click(); + await next.click(); + await expect(next).toHaveText(/Submit answers/); + await next.click(); + + // It didn't take: the form stays, with every answer and why. + const failed = form.getByTestId("question-form-failed"); + await expect(failed).toBeVisible(); + await expect(failed).toContainText("Your answers didn't reach Claude"); + await expect(failed).toContainText("Claude's screen doesn't show its questions"); + await expect(form).toContainText("Review your answers"); + await expect(form).toContainText(words); + await expect(form).toContainText("Unit tests"); + + // Copy answers: each under its question's name, ready to paste. + await app.context.grantPermissions(["clipboard-read", "clipboard-write"]); + await failed.getByRole("button", { name: "Copy answers" }).click(); + await expect.poll(() => app.page.evaluate(() => navigator.clipboard.readText())).toBe(`Release: ${words}\nChecks: Unit tests\nReviewer: bailey`); + + // Retry goes, and the form becomes what was answered. + await failed.getByRole("button", { name: "Retry" }).click(); + await expect(form).toHaveCount(0); + const answered = app.chat.locator("[data-testid=chat-item][data-kind=question]"); + await expect(answered).toContainText("Answered 3 questions"); + await expect(answered).toContainText(words); +}); + test("the artifacts chip lists the pages an agent published", async ({ app }) => { mockOnly(); await app.openWorktree("devl/search-perf"); diff --git a/app/src/components/conversation/conversation-pane.tsx b/app/src/components/conversation/conversation-pane.tsx index ed289ada..b3775625 100644 --- a/app/src/components/conversation/conversation-pane.tsx +++ b/app/src/components/conversation/conversation-pane.tsx @@ -388,11 +388,13 @@ export function ConversationPane({ box, session, agent: remembered, visible, onS }; // The whole form's answers, filled in on the box; when they don't take, - // the agent's own screen opens to finish there, saying why. + // the agent's own screen opens under the form, which keeps them, saying + // why. A retry that takes closes it. const submitQuestions = async (tool: string, answers: QuestionAnswer[]) => { - if (!client) throw new Error("Not connected"); try { + if (!client) throw new Error("Not connected"); await answerQuestions(client, box, session, { tool, answers }); + setStuck(undefined); } catch (err) { setStuck({ at: s?.state_since, why: errorMessage(err) }); setNudge((n) => n + 1); diff --git a/app/src/components/conversation/question-form.tsx b/app/src/components/conversation/question-form.tsx index d1e70bf3..cbd80135 100644 --- a/app/src/components/conversation/question-form.tsx +++ b/app/src/components/conversation/question-form.tsx @@ -1,13 +1,16 @@ -import { ArrowLeftIcon, ArrowRightIcon, CheckIcon, ListChecksIcon, PencilIcon, SendHorizontalIcon, SquareTerminalIcon, XIcon } from "lucide-react"; +import { ArrowLeftIcon, ArrowRightIcon, CheckIcon, CopyIcon, ListChecksIcon, PencilIcon, RotateCwIcon, SendHorizontalIcon, SquareTerminalIcon, TriangleAlertIcon, XIcon } from "lucide-react"; import { createContext, type ReactNode, useContext, useEffect, useRef, useState } from "react"; +import { ErrorText } from "@/components/error-note"; +import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Radio, RadioGroup } from "@/components/ui/radio-group"; import { Spinner } from "@/components/ui/spinner"; -import { complete, type Question, type QuestionAnswer, shownAnswer } from "@/lib/questions"; +import { copyText } from "@/lib/clipboard"; +import { answersText, complete, type Question, type QuestionAnswer, shownAnswer } from "@/lib/questions"; import type { TranscriptItem } from "@/lib/transcript"; import { cn } from "@/lib/utils"; @@ -23,8 +26,8 @@ export interface QuestionsActions { live?: string; // The chat can fill in the form: Claude Code, on a box that answers. canAnswer: boolean; - // Why it couldn't, when it tried: the person finishes in the agent's - // own screen, open below. + // Why it couldn't, when it tried: the form keeps every answer, to try + // again or copy, and the agent's own screen opens below to finish in. stuck?: string; submit(tool: string, answers: QuestionAnswer[]): Promise; } @@ -38,13 +41,14 @@ export function QuestionCard({ it, who }: { it: Item; who: string }) { const [sent, setSent] = useState(); if (it.done) return ; const live = !!ctx && !!it.tool && ctx.live === it.tool; - if (sent && !ctx?.stuck) return ; + if (sent) return ; if (!live) return ; - if (!ctx.canAnswer || ctx.stuck) return ; + if (!ctx.canAnswer) return ; return (
{ await ctx.submit(it.tool ?? "", answers); setSent(answers); @@ -139,7 +143,7 @@ function Asked({ it }: { it: Item }) { // The questions, when only the agent's own screen can answer them: its // terminal opens under the conversation. -function InTerminal({ it, who, reason }: { it: Item; who: string; reason?: string }) { +function InTerminal({ it, who }: { it: Item; who: string }) { return (
@@ -166,7 +170,7 @@ function InTerminal({ it, who, reason }: { it: Item; who: string; reason?: strin
- {reason ? `${reason}.` : `Answer in ${who}'s screen below.`} + Answer in {who}'s screen below.
); @@ -178,7 +182,7 @@ type Draft = { picks: string[]; other: string; otherOn: boolean }; const toAnswer = (d: Draft): QuestionAnswer => ({ picks: d.picks, other: d.otherOn ? d.other.replace(/\s+/g, " ").trim() : "" }); -function Form({ it, who, onSubmit }: { it: Item; who: string; onSubmit(answers: QuestionAnswer[]): Promise }) { +function Form({ it, who, stuck, onSubmit }: { it: Item; who: string; stuck?: string; onSubmit(answers: QuestionAnswer[]): Promise }) { const qs = it.questions; const [drafts, setDrafts] = useState(() => qs.map(() => ({ picks: [], other: "", otherOn: false }))); // One question with one pick goes as soon as it is picked, as the @@ -202,19 +206,19 @@ function Form({ it, who, onSubmit }: { it: Item; who: string; onSubmit(answers: body.current?.querySelector('[role=radio][tabindex="0"], [role=radio], [role=checkbox], textarea, input:not([type=hidden]):not([aria-hidden=true]), button:not([disabled])')?.focus({ preventScroll: true }); }, [step]); const [busy, setBusy] = useState(false); - const [error, setError] = useState(); const answers = drafts.map(toAnswer); + // The box couldn't fill in the agent's form: nothing here is lost. + const failed = busy ? undefined : stuck; const done = qs.map((q, i) => complete(q, answers[i])); const review = reviewed && step === qs.length; const q = qs[Math.min(step, qs.length - 1)]; const set = (i: number, f: (d: Draft) => Draft) => setDrafts((ds) => ds.map((d, j) => (j === i ? f(d) : d))); const submit = async () => { setBusy(true); - setError(undefined); try { await onSubmit(answers); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + } catch { + // Why is the pane's (stuck), for this wait. setBusy(false); } }; @@ -269,6 +273,28 @@ function Form({ it, who, onSubmit }: { it: Item; who: string; onSubmit(answers: set(step, f)} onEnter={next} /> )} + {failed && ( +
+ + + Your answers didn't reach {who} + + + They're kept here: try again, or copy them and finish in {who}'s screen below. + + + + + + +
+ )}
{steps > 1 && ( )} - - {error ? error : busy ? `Answering in ${who}'s form…` : review ? "Check your answers, then submit" : `${who} waits for your answer`} - + {busy ? `Answering in ${who}'s form…` : failed ? "Not sent yet" : review ? "Check your answers, then submit" : `${who} waits for your answer`}