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