From 8cfc590c7eca87396be22d5352ec83d352ad2eaf Mon Sep 17 00:00:00 2001 From: kami Date: Tue, 7 Jul 2026 18:29:56 +0400 Subject: [PATCH] fix(tui): soft-wrap clarification question prompts The clarification modal hard-truncated each question's head line with clip(), so long prompts (e.g. an API question and an Architecture question) were cut at the modal edge and unreadable. Wrap the prompt across rows instead: wrapPrompt() greedily word-wraps with a narrower first line (marker + header chip eat into it) and full-width continuation lines, indented to align under the prompt. --- apps/tui-go/internal/app/clarcard.go | 47 ++++++++++++++++++++--- apps/tui-go/internal/app/clarcard_test.go | 37 ++++++++++++++++++ 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/apps/tui-go/internal/app/clarcard.go b/apps/tui-go/internal/app/clarcard.go index a474d778..d0f9295f 100644 --- a/apps/tui-go/internal/app/clarcard.go +++ b/apps/tui-go/internal/app/clarcard.go @@ -281,15 +281,28 @@ func (m Model) clarQuestionLines(i int, q ClarQuestion, focused bool, w int) str marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▸ ") promptFg = t.P.FgStrong } - head := marker + promptStyle := lipgloss.NewStyle().Foreground(promptFg).Background(t.P.BgPanel) + + headChip, headW := "", 2 // marker is 2 cols if q.Header != "" { - head += lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Render("[" + q.Header + "] ") + headChip = lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Render("[" + q.Header + "] ") + headW += len(q.Header) + 3 // "[" + header + "] " } - head += lipgloss.NewStyle().Foreground(promptFg).Background(t.P.BgPanel).Render(q.Prompt) + prompt := q.Prompt if q.MultiSelect { - head += mbg(t, " (multi)", t.P.Faint) + prompt += " (multi)" + } + // Soft-wrap the prompt so long questions (e.g. the [API]/[Architecture] ones) + // stay fully readable instead of being clipped at the modal edge. The first line + // flows after the marker + header chip; continuation lines indent under the prompt. + contIndent := mbg(t, " ", t.P.BgPanel) + for li, line := range wrapPrompt(prompt, max(w-headW, 8), max(w-4, 8)) { + if li == 0 { + b.WriteString(marker + headChip + promptStyle.Render(line) + "\n") + } else { + b.WriteString(contIndent + promptStyle.Render(line) + "\n") + } } - b.WriteString(clip(head, w) + "\n") chips := make([]string, 0, len(q.Options)+1) for oi, opt := range q.Options { @@ -304,6 +317,30 @@ func (m Model) clarQuestionLines(i int, q ClarQuestion, focused bool, w int) str return b.String() } +// wrapPrompt greedily word-wraps s, giving the first line firstW columns (it shares +// its row with the marker + header chip) and every continuation line contW. A word +// longer than the limit occupies its own line rather than being truncated. +func wrapPrompt(s string, firstW, contW int) []string { + words := strings.Fields(s) + if len(words) == 0 { + return []string{""} + } + var lines []string + cur, limit := "", firstW + for _, wd := range words { + switch { + case cur == "": + cur = wd + case len(cur)+1+len(wd) <= limit: + cur += " " + wd + default: + lines = append(lines, cur) + cur, limit = wd, contW + } + } + return append(lines, cur) +} + // clarCustomChip renders the free-text slot: "+ custom" when empty, the typed text // (with a caret while editing) otherwise. func (m Model) clarCustomChip(i int, q ClarQuestion, focused bool) string { diff --git a/apps/tui-go/internal/app/clarcard_test.go b/apps/tui-go/internal/app/clarcard_test.go index 1b8e031c..900ad91a 100644 --- a/apps/tui-go/internal/app/clarcard_test.go +++ b/apps/tui-go/internal/app/clarcard_test.go @@ -39,6 +39,43 @@ func TestClarificationEntersStateAndRenders(t *testing.T) { } } +func TestClarificationLongPromptSoftWraps(t *testing.T) { + m := clarModel() + m.width, m.height = 60, 40 // narrow modal so a long prompt must wrap + longPrompt := "Which API surface should the web UI talk to for streaming session " + + "events and how should it authenticate against the server endpoints?" + m.applyServer(protocol.ServerMessage{ + Type: protocol.TypeClarification, + SessionID: "s1", + RequestID: "req-2", + StageID: "analyst", + Questions: []protocol.ClarificationQuestionDto{ + {ID: "api", Prompt: longPrompt, Options: []string{"REST", "WebSocket"}, Header: "API"}, + }, + }) + out := m.clarificationModal() + // Every word of the prompt must survive — nothing clipped at the modal edge. + for _, word := range strings.Fields(longPrompt) { + if !strings.Contains(out, word) { + t.Fatalf("wrapped prompt dropped %q:\n%s", word, out) + } + } + // And it must actually span multiple rendered rows (i.e. it wrapped, not overflowed). + if len(strings.Split(strings.TrimRight(out, "\n"), "\n")) < 4 { + t.Fatalf("expected the long prompt to wrap across rows:\n%s", out) + } +} + +func TestWrapPromptFirstLineNarrowerThanContinuation(t *testing.T) { + lines := wrapPrompt("one two three four five six", 7, 20) + if lines[0] != "one two" { + t.Fatalf("first line should honor firstW=7, got %q", lines[0]) + } + if len(lines) < 2 { + t.Fatalf("expected continuation lines, got %v", lines) + } +} + func TestClarificationSelectAndSubmit(t *testing.T) { m := clarModel() // move the option cursor to "Vue" (index 1) and select it