feat(tui-go): interactive clarification question view
Render stage clarification questions as an AskUserQuestion-style form: header chip, prompt, selectable option chips, and a free-text custom slot. Arrow/hjkl nav, space to select, e for custom, enter to submit. Submit guards on all-answered and sends ClarificationResponse; the parked stage re-runs with the answers in context.
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/correx/tui-go/internal/protocol"
|
||||
)
|
||||
|
||||
// clarcard.go renders and drives the clarification view: a stage's open questions
|
||||
// presented as an interactive form — a header chip, the prompt, a row of selectable
|
||||
// option chips, and a custom free-text slot, like the AskUserQuestion widget.
|
||||
// Submitting sends the answers back; the parked stage re-runs with them in context.
|
||||
|
||||
// --- per-question widget state ---
|
||||
|
||||
func (m *Model) clarInitState(n int) {
|
||||
m.clarFocus = 0
|
||||
m.clarCursor = 0
|
||||
m.clarTyping = false
|
||||
m.clarDismissed = false
|
||||
m.clarChosen = make([]map[int]bool, n)
|
||||
for i := range m.clarChosen {
|
||||
m.clarChosen[i] = map[int]bool{}
|
||||
}
|
||||
m.clarText = make([]string, n)
|
||||
}
|
||||
|
||||
func (m *Model) clarResetState() {
|
||||
m.clarChosen = nil
|
||||
m.clarText = nil
|
||||
m.clarFocus = 0
|
||||
m.clarCursor = 0
|
||||
m.clarTyping = false
|
||||
m.clarDismissed = false
|
||||
}
|
||||
|
||||
// clarEnsureState (re)initializes the per-question buffers when they don't match the
|
||||
// selected session's question set — e.g. a clarification that arrived for a session
|
||||
// before it was selected, so onClarificationRequired didn't size the buffers.
|
||||
func (m *Model) clarEnsureState() {
|
||||
s := m.session(m.selectedID)
|
||||
if s == nil || s.Clar == nil {
|
||||
return
|
||||
}
|
||||
if len(m.clarChosen) != len(s.Clar.Questions) || len(m.clarText) != len(s.Clar.Questions) {
|
||||
m.clarInitState(len(s.Clar.Questions))
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) clarChosenAt(i, oi int) bool {
|
||||
if i < 0 || i >= len(m.clarChosen) || m.clarChosen[i] == nil {
|
||||
return false
|
||||
}
|
||||
return m.clarChosen[i][oi]
|
||||
}
|
||||
|
||||
func (m Model) clarTextAt(i int) string {
|
||||
if i < 0 || i >= len(m.clarText) {
|
||||
return ""
|
||||
}
|
||||
return m.clarText[i]
|
||||
}
|
||||
|
||||
func (m Model) clarAnswered(i int, q ClarQuestion) bool {
|
||||
if strings.TrimSpace(m.clarTextAt(i)) != "" {
|
||||
return true
|
||||
}
|
||||
for oi := range q.Options {
|
||||
if m.clarChosenAt(i, oi) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// clarAnswerValue is the submitted value for question i: the custom text if present,
|
||||
// else the chosen option(s) joined in option order.
|
||||
func (m Model) clarAnswerValue(i int, q ClarQuestion) string {
|
||||
if t := strings.TrimSpace(m.clarTextAt(i)); t != "" {
|
||||
return t
|
||||
}
|
||||
var sel []string
|
||||
for oi, opt := range q.Options {
|
||||
if m.clarChosenAt(i, oi) {
|
||||
sel = append(sel, opt)
|
||||
}
|
||||
}
|
||||
return strings.Join(sel, ", ")
|
||||
}
|
||||
|
||||
// --- key handling ---
|
||||
|
||||
func (m Model) handleClarificationKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
s := m.session(m.selectedID)
|
||||
if s == nil || s.Clar == nil {
|
||||
return m, nil
|
||||
}
|
||||
qs := s.Clar.Questions
|
||||
if m.clarTyping {
|
||||
return m.handleClarTyping(k), nil
|
||||
}
|
||||
switch k.Type {
|
||||
case tea.KeyEsc:
|
||||
m.clarDismissed = true
|
||||
case tea.KeyUp:
|
||||
m.clarFocusMove(-1, qs)
|
||||
case tea.KeyDown:
|
||||
m.clarFocusMove(1, qs)
|
||||
case tea.KeyLeft:
|
||||
m.clarCursorMove(-1, qs)
|
||||
case tea.KeyRight:
|
||||
m.clarCursorMove(1, qs)
|
||||
case tea.KeySpace:
|
||||
m.clarSelect(qs)
|
||||
case tea.KeyEnter:
|
||||
return m.clarSubmit()
|
||||
case tea.KeyRunes:
|
||||
switch string(k.Runes) {
|
||||
case "k":
|
||||
m.clarFocusMove(-1, qs)
|
||||
case "j":
|
||||
m.clarFocusMove(1, qs)
|
||||
case "h":
|
||||
m.clarCursorMove(-1, qs)
|
||||
case "l":
|
||||
m.clarCursorMove(1, qs)
|
||||
case "e":
|
||||
m.clarCursor = len(qs[m.clarFocus].Options) // jump to the custom slot
|
||||
m.clarTyping = true
|
||||
case "s":
|
||||
return m.clarSubmit()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) handleClarTyping(k tea.KeyMsg) tea.Model {
|
||||
if m.clarFocus >= len(m.clarText) {
|
||||
return m
|
||||
}
|
||||
switch k.Type {
|
||||
case tea.KeyEsc:
|
||||
m.clarTyping = false
|
||||
case tea.KeyEnter:
|
||||
m.clarTyping = false
|
||||
// committing custom text supersedes any chosen option for this question
|
||||
if m.clarFocus < len(m.clarChosen) {
|
||||
m.clarChosen[m.clarFocus] = map[int]bool{}
|
||||
}
|
||||
case tea.KeyBackspace:
|
||||
if t := m.clarText[m.clarFocus]; len(t) > 0 {
|
||||
m.clarText[m.clarFocus] = t[:len(t)-1]
|
||||
}
|
||||
case tea.KeyRunes, tea.KeySpace:
|
||||
m.clarText[m.clarFocus] += string(k.Runes)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Model) clarFocusMove(d int, qs []ClarQuestion) {
|
||||
if len(qs) == 0 {
|
||||
return
|
||||
}
|
||||
m.clarFocus = clampInt(m.clarFocus+d, 0, len(qs)-1)
|
||||
m.clarCursor = clampInt(m.clarCursor, 0, len(qs[m.clarFocus].Options))
|
||||
}
|
||||
|
||||
func (m *Model) clarCursorMove(d int, qs []ClarQuestion) {
|
||||
if m.clarFocus >= len(qs) {
|
||||
return
|
||||
}
|
||||
m.clarCursor = clampInt(m.clarCursor+d, 0, len(qs[m.clarFocus].Options))
|
||||
}
|
||||
|
||||
func (m *Model) clarSelect(qs []ClarQuestion) {
|
||||
if m.clarFocus >= len(qs) || m.clarFocus >= len(m.clarChosen) {
|
||||
return
|
||||
}
|
||||
q := qs[m.clarFocus]
|
||||
if m.clarCursor >= len(q.Options) { // custom slot
|
||||
m.clarTyping = true
|
||||
return
|
||||
}
|
||||
if q.MultiSelect {
|
||||
if m.clarChosen[m.clarFocus] == nil {
|
||||
m.clarChosen[m.clarFocus] = map[int]bool{}
|
||||
}
|
||||
m.clarChosen[m.clarFocus][m.clarCursor] = !m.clarChosen[m.clarFocus][m.clarCursor]
|
||||
} else {
|
||||
m.clarChosen[m.clarFocus] = map[int]bool{m.clarCursor: true}
|
||||
}
|
||||
if m.clarFocus < len(m.clarText) {
|
||||
m.clarText[m.clarFocus] = "" // choosing an option supersedes a typed answer
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) clarSubmit() (tea.Model, tea.Cmd) {
|
||||
s := m.session(m.selectedID)
|
||||
if s == nil || s.Clar == nil {
|
||||
return m, nil
|
||||
}
|
||||
qs := s.Clar.Questions
|
||||
for i, q := range qs {
|
||||
if !m.clarAnswered(i, q) {
|
||||
m.clarFocus = i // nudge to the first unanswered rather than submitting partial
|
||||
m.clarCursor = 0
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
answers := make([]protocol.ClarificationAnswerDto, 0, len(qs))
|
||||
for i, q := range qs {
|
||||
answers = append(answers, protocol.ClarificationAnswerDto{
|
||||
QuestionID: q.ID,
|
||||
Value: m.clarAnswerValue(i, q),
|
||||
})
|
||||
}
|
||||
m.client.Send(protocol.ClarificationResponse(s.Clar.SessionID, s.Clar.StageID, s.Clar.RequestID, answers))
|
||||
s.Clar = nil
|
||||
m.clarResetState()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func clampInt(v, lo, hi int) int {
|
||||
if v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// --- rendering ---
|
||||
|
||||
func (m Model) clarificationModal() string {
|
||||
t := m.theme
|
||||
w := m.modalWidth()
|
||||
s := m.session(m.selectedID)
|
||||
if s == nil || s.Clar == nil {
|
||||
return ""
|
||||
}
|
||||
qs := s.Clar.Questions
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(m.titleLine("clarifying questions"))
|
||||
b.WriteString(mbg(t, " — "+s.Clar.StageID, t.P.Dim))
|
||||
answered := 0
|
||||
for i, q := range qs {
|
||||
if m.clarAnswered(i, q) {
|
||||
answered++
|
||||
}
|
||||
}
|
||||
b.WriteString(mbg(t, " ("+itoa(answered)+"/"+itoa(len(qs))+" answered)", t.P.Faint) + "\n\n")
|
||||
|
||||
contentW := w - 6
|
||||
for i, q := range qs {
|
||||
b.WriteString(m.clarQuestionLines(i, q, i == m.clarFocus, contentW))
|
||||
if i < len(qs)-1 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{
|
||||
{"←→", "option"}, {"↑↓", "question"}, {"space", "select"},
|
||||
{"e", "custom"}, {"enter", "submit"}, {"esc", "later"},
|
||||
}))
|
||||
return t.Overlay.Width(w).Render(b.String())
|
||||
}
|
||||
|
||||
// clarQuestionLines renders one question: a focus marker + header chip + prompt, then
|
||||
// a wrapped row of option chips (cursor highlighted, chosen marked) plus a custom slot.
|
||||
func (m Model) clarQuestionLines(i int, q ClarQuestion, focused bool, w int) string {
|
||||
t := m.theme
|
||||
var b strings.Builder
|
||||
|
||||
marker := mbg(t, " ", t.P.BgPanel)
|
||||
promptFg := t.P.Fg
|
||||
if focused {
|
||||
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▸ ")
|
||||
promptFg = t.P.FgStrong
|
||||
}
|
||||
head := marker
|
||||
if q.Header != "" {
|
||||
head += lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Render("[" + q.Header + "] ")
|
||||
}
|
||||
head += lipgloss.NewStyle().Foreground(promptFg).Background(t.P.BgPanel).Render(q.Prompt)
|
||||
if q.MultiSelect {
|
||||
head += mbg(t, " (multi)", t.P.Faint)
|
||||
}
|
||||
b.WriteString(clip(head, w) + "\n")
|
||||
|
||||
chips := make([]string, 0, len(q.Options)+1)
|
||||
for oi, opt := range q.Options {
|
||||
chips = append(chips, m.clarChip(opt, m.clarChosenAt(i, oi), focused && m.clarCursor == oi))
|
||||
}
|
||||
chips = append(chips, m.clarCustomChip(i, q, focused))
|
||||
|
||||
gap := mbg(t, " ", t.P.BgPanel)
|
||||
for _, line := range wrapStyled(chips, gap, 2, w-4) {
|
||||
b.WriteString(mbg(t, " ", t.P.BgPanel) + line + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// 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 {
|
||||
text := m.clarTextAt(i)
|
||||
chosen := strings.TrimSpace(text) != ""
|
||||
cursor := focused && m.clarCursor >= len(q.Options)
|
||||
label := "+ custom"
|
||||
switch {
|
||||
case cursor && m.clarTyping:
|
||||
label = "✎ " + text + "▏"
|
||||
case chosen:
|
||||
label = "✎ " + text
|
||||
}
|
||||
return m.clarChip(label, chosen, cursor)
|
||||
}
|
||||
|
||||
// clarChip renders one option as a bracketed badge: inverse-highlighted when the
|
||||
// cursor is on it, marked (●) when chosen.
|
||||
func (m Model) clarChip(label string, chosen, cursor bool) string {
|
||||
t := m.theme
|
||||
mark, fg := "○ ", t.P.Dim
|
||||
if chosen {
|
||||
mark, fg = "● ", t.P.Accent2
|
||||
}
|
||||
style := lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel)
|
||||
if cursor {
|
||||
style = lipgloss.NewStyle().Foreground(t.P.BgPanel).Background(t.P.Accent).Bold(true)
|
||||
}
|
||||
return style.Render(" " + mark + label + " ")
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/correx/tui-go/internal/protocol"
|
||||
"github.com/correx/tui-go/internal/ws"
|
||||
)
|
||||
|
||||
func clarModel() Model {
|
||||
m := NewModel(ws.New("", 0)) // unconnected; Send just buffers
|
||||
m.width, m.height = 120, 40
|
||||
m.theme = NewTheme(SoftBlue)
|
||||
m.selectedID = "s1"
|
||||
m.sessionEntered = true
|
||||
m.applyServer(protocol.ServerMessage{
|
||||
Type: protocol.TypeClarification,
|
||||
SessionID: "s1",
|
||||
RequestID: "req-1",
|
||||
StageID: "analyst",
|
||||
Questions: []protocol.ClarificationQuestionDto{
|
||||
{ID: "stack", Prompt: "Which stack?", Options: []string{"React", "Vue"}, Header: "Stack"},
|
||||
},
|
||||
})
|
||||
return m
|
||||
}
|
||||
|
||||
func TestClarificationEntersStateAndRenders(t *testing.T) {
|
||||
m := clarModel()
|
||||
if m.displayState() != StateClarification {
|
||||
t.Fatalf("want StateClarification, got %v", m.displayState())
|
||||
}
|
||||
out := m.clarificationModal()
|
||||
for _, want := range []string{"clarifying questions", "Which stack?", "React", "Vue", "Stack", "custom"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("modal missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClarificationSelectAndSubmit(t *testing.T) {
|
||||
m := clarModel()
|
||||
// move the option cursor to "Vue" (index 1) and select it
|
||||
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyRight})
|
||||
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeySpace})
|
||||
if !m.clarChosenAt(0, 1) {
|
||||
t.Fatalf("expected option 1 chosen, chosen=%v", m.clarChosen)
|
||||
}
|
||||
// submit
|
||||
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if s := m.session("s1"); s == nil || s.Clar != nil {
|
||||
t.Fatalf("expected clarification cleared after submit")
|
||||
}
|
||||
if m.displayState() != StateInSession {
|
||||
t.Fatalf("want StateInSession after submit, got %v", m.displayState())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClarificationSubmitGuardWaitsForAllAnswers(t *testing.T) {
|
||||
m := clarModel()
|
||||
m.applyServer(protocol.ServerMessage{
|
||||
Type: protocol.TypeClarification, SessionID: "s1", RequestID: "req-2", StageID: "analyst",
|
||||
Questions: []protocol.ClarificationQuestionDto{
|
||||
{ID: "a", Prompt: "Q A?", Options: []string{"x"}},
|
||||
{ID: "b", Prompt: "Q B?", Options: []string{"y"}},
|
||||
},
|
||||
})
|
||||
// answer only the first question, then attempt submit
|
||||
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeySpace})
|
||||
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if s := m.session("s1"); s == nil || s.Clar == nil {
|
||||
t.Fatalf("expected clarification to remain with partial answers")
|
||||
}
|
||||
if m.clarFocus != 1 {
|
||||
t.Fatalf("expected focus nudged to first unanswered (1), got %d", m.clarFocus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClarificationCustomTextAnswer(t *testing.T) {
|
||||
m := clarModel()
|
||||
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")})
|
||||
if !m.clarTyping {
|
||||
t.Fatalf("expected typing mode after 'e'")
|
||||
}
|
||||
for _, r := range "Solid" {
|
||||
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||
}
|
||||
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if m.clarTyping {
|
||||
t.Fatalf("expected typing committed")
|
||||
}
|
||||
if m.clarTextAt(0) != "Solid" {
|
||||
t.Fatalf("want custom text 'Solid', got %q", m.clarTextAt(0))
|
||||
}
|
||||
if !m.clarAnswered(0, m.session("s1").Clar.Questions[0]) {
|
||||
t.Fatalf("expected question answered via custom text")
|
||||
}
|
||||
}
|
||||
|
||||
func applyClarKey(m Model, k tea.KeyMsg) Model {
|
||||
updated, _ := m.handleClarificationKey(k)
|
||||
return updated.(Model)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ const (
|
||||
StateIdle DisplayState = iota
|
||||
StateInSession
|
||||
StateApproval
|
||||
StateClarification
|
||||
)
|
||||
|
||||
// InputMode toggles what a submitted line targets.
|
||||
@@ -107,6 +108,23 @@ type Approval struct {
|
||||
Rationale []string
|
||||
}
|
||||
|
||||
// ClarQuestion is one open question a stage raised (mirrors the wire DTO).
|
||||
type ClarQuestion struct {
|
||||
ID string
|
||||
Prompt string
|
||||
Header string
|
||||
Options []string
|
||||
MultiSelect bool
|
||||
}
|
||||
|
||||
// Clarification is a stage's pending set of open questions for the operator.
|
||||
type Clarification struct {
|
||||
RequestID string
|
||||
SessionID string
|
||||
StageID string
|
||||
Questions []ClarQuestion
|
||||
}
|
||||
|
||||
// Session is the UI's view of one server session.
|
||||
type Session struct {
|
||||
ID string
|
||||
@@ -122,6 +140,7 @@ type Session struct {
|
||||
ToolsByStage map[string][]ManifestTool
|
||||
Events []EventEntry
|
||||
Pending *Approval
|
||||
Clar *Clarification // open questions awaiting answers (clarification view)
|
||||
Active bool // an inference/tool is in flight (drives the spinner)
|
||||
}
|
||||
|
||||
@@ -229,6 +248,14 @@ type Model struct {
|
||||
// approval steering input buffer
|
||||
steerBuffer string
|
||||
steering bool
|
||||
|
||||
// clarification view (the interactive question form)
|
||||
clarFocus int // focused question index
|
||||
clarCursor int // option cursor in the focused question (== len(opts) → custom slot)
|
||||
clarChosen []map[int]bool // per-question selected option indices
|
||||
clarText []string // per-question free-text / custom answer
|
||||
clarTyping bool // typing into the custom buffer for the focused question
|
||||
clarDismissed bool // peeked away from the form (it can be reopened)
|
||||
}
|
||||
|
||||
// NewModel builds the initial idle state.
|
||||
@@ -257,10 +284,16 @@ func (m Model) displayState() DisplayState {
|
||||
if m.selectedID == "" || !m.sessionEntered {
|
||||
return StateIdle
|
||||
}
|
||||
s := m.session(m.selectedID)
|
||||
// A stage's open questions take precedence: the run is parked on them, and they
|
||||
// must be answered (or peeked away) before anything else makes sense.
|
||||
if s != nil && s.Clar != nil && !m.clarDismissed {
|
||||
return StateClarification
|
||||
}
|
||||
if m.approvalDismissed {
|
||||
return StateInSession
|
||||
}
|
||||
if s := m.session(m.selectedID); s != nil && s.Pending != nil {
|
||||
if s != nil && s.Pending != nil {
|
||||
return StateApproval
|
||||
}
|
||||
return StateInSession
|
||||
|
||||
@@ -132,11 +132,13 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
||||
m.touch(msg.SessionID, "COMPLETED")
|
||||
if s := m.session(msg.SessionID); s != nil {
|
||||
s.Active = false
|
||||
s.Clar = nil
|
||||
}
|
||||
case protocol.TypeSessionFailed:
|
||||
m.touch(msg.SessionID, "FAILED")
|
||||
if s := m.session(msg.SessionID); s != nil {
|
||||
s.Active = false
|
||||
s.Clar = nil
|
||||
}
|
||||
case protocol.TypeStageStarted:
|
||||
if s := m.session(msg.SessionID); s != nil {
|
||||
@@ -242,6 +244,8 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
||||
}
|
||||
case protocol.TypeApprovalRequired:
|
||||
m.onApprovalRequired(msg)
|
||||
case protocol.TypeClarification:
|
||||
m.onClarificationRequired(msg)
|
||||
case protocol.TypeApprovalResolved:
|
||||
if s := m.session(msg.SessionID); s != nil {
|
||||
s.Pending = nil
|
||||
@@ -400,6 +404,26 @@ func (m *Model) onApprovalRequired(msg protocol.ServerMessage) {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) onClarificationRequired(msg protocol.ServerMessage) {
|
||||
qs := make([]ClarQuestion, 0, len(msg.Questions))
|
||||
for _, q := range msg.Questions {
|
||||
qs = append(qs, ClarQuestion{
|
||||
ID: q.ID, Prompt: q.Prompt, Header: q.Header,
|
||||
Options: q.Options, MultiSelect: q.MultiSelect,
|
||||
})
|
||||
}
|
||||
c := &Clarification{
|
||||
RequestID: msg.RequestID, SessionID: msg.SessionID,
|
||||
StageID: msg.StageID, Questions: qs,
|
||||
}
|
||||
if s := m.session(msg.SessionID); s != nil {
|
||||
s.Clar = c
|
||||
}
|
||||
if msg.SessionID == m.selectedID {
|
||||
m.clarInitState(len(qs))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) onSnapshot(msg protocol.ServerMessage) {
|
||||
var pending *Approval
|
||||
if len(msg.PendingAppr) > 0 {
|
||||
|
||||
@@ -104,6 +104,10 @@ func (m Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if m.overlay != OverlayNone {
|
||||
return m.handleOverlayKey(k)
|
||||
}
|
||||
if m.displayState() == StateClarification {
|
||||
m.clarEnsureState()
|
||||
return m.handleClarificationKey(k)
|
||||
}
|
||||
if m.editMode == ModeInsert {
|
||||
return m.handleInsertKey(k)
|
||||
}
|
||||
|
||||
@@ -55,6 +55,9 @@ func (m Model) View() string {
|
||||
m.renderFooter(),
|
||||
)
|
||||
|
||||
if m.displayState() == StateClarification {
|
||||
return m.center(m.clarificationModal())
|
||||
}
|
||||
if m.overlay != OverlayNone {
|
||||
return m.renderOverlay(base)
|
||||
}
|
||||
@@ -218,6 +221,9 @@ func (m Model) renderFooter() string {
|
||||
case m.displayState() == StateApproval:
|
||||
// Approval actions live in the band itself; the footer offers navigation only.
|
||||
hints = []string{hint("l", "back"), hint("e", "events"), hint("q", "quit")}
|
||||
case m.displayState() == StateClarification:
|
||||
// The question form owns its keys; the footer mirrors the essentials.
|
||||
hints = []string{hint("←→", "option"), hint("↑↓", "question"), hint("spc", "select"), hint("enter", "submit"), hint("esc", "later")}
|
||||
case m.displayState() == StateIdle:
|
||||
if nrw {
|
||||
hints = []string{hint("i", "name"), hint("/", "filter"), hint("w", "wf"), hint("p", "cmds"), hint("q", "quit")}
|
||||
|
||||
@@ -149,6 +149,27 @@ func TestDecodeGoldenServerFrames(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "clarification.required carries structured questions",
|
||||
json: `{"type":"clarification.required","sessionId":"s1","requestId":"req-1","stageId":"analyst",` +
|
||||
`"questions":[{"id":"stack","prompt":"Which stack?","options":["React","Vue"],"multiSelect":false,"header":"Stack"}],` +
|
||||
`"sequence":9,"sessionSequence":4}`,
|
||||
wantType: TypeClarification,
|
||||
eventBear: true,
|
||||
check: func(t *testing.T, m ServerMessage) {
|
||||
if m.RequestID != "req-1" || m.StageID != "analyst" {
|
||||
t.Fatalf("clarification ids: %+v", m)
|
||||
}
|
||||
if len(m.Questions) != 1 {
|
||||
t.Fatalf("clarification questions: %+v", m.Questions)
|
||||
}
|
||||
q := m.Questions[0]
|
||||
if q.Prompt != "Which stack?" || q.Header != "Stack" ||
|
||||
len(q.Options) != 2 || q.Options[0] != "React" {
|
||||
t.Fatalf("clarification question fields: %+v", q)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
@@ -193,6 +214,25 @@ func TestEncodeGoldenClientFrames(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ClarificationResponse carries ids + answers",
|
||||
frame: ClarificationResponse("s1", "analyst", "req-1",
|
||||
[]ClarificationAnswerDto{{QuestionID: "stack", Value: "React"}}),
|
||||
wantType: clientPrefix + "ClarificationResponse",
|
||||
check: func(t *testing.T, raw map[string]any) {
|
||||
if raw["sessionId"] != "s1" || raw["stageId"] != "analyst" || raw["requestId"] != "req-1" {
|
||||
t.Fatalf("ClarificationResponse ids: %+v", raw)
|
||||
}
|
||||
answers, ok := raw["answers"].([]any)
|
||||
if !ok || len(answers) != 1 {
|
||||
t.Fatalf("ClarificationResponse answers: %+v", raw["answers"])
|
||||
}
|
||||
a := answers[0].(map[string]any)
|
||||
if a["questionId"] != "stack" || a["value"] != "React" {
|
||||
t.Fatalf("ClarificationResponse answer: %+v", a)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
|
||||
@@ -34,6 +34,7 @@ const (
|
||||
TypeToolRejected = "tool.rejected"
|
||||
TypeApprovalRequired = "approval.required"
|
||||
TypeApprovalResolved = "approval.resolved"
|
||||
TypeClarification = "clarification.required"
|
||||
TypeWorkspaceBound = "session.workspace_bound"
|
||||
TypeStageManifest = "stage.tool_manifest"
|
||||
TypeProviderStatus = "provider.status_changed"
|
||||
@@ -135,6 +136,26 @@ type ServerMessage struct {
|
||||
|
||||
// session.stats — derived metrics for a session (reply to GetSessionStats)
|
||||
Stats *StatsDto `json:"stats"`
|
||||
|
||||
// clarification.required — open questions a stage raised for the operator
|
||||
Questions []ClarificationQuestionDto `json:"questions"`
|
||||
}
|
||||
|
||||
// ClarificationQuestionDto is one open question from a stage. Empty Options ->
|
||||
// free-text answer; non-empty -> selectable choices. Mirrors the Kotlin
|
||||
// ClarificationQuestion (core:events), which is the wire shape.
|
||||
type ClarificationQuestionDto struct {
|
||||
ID string `json:"id"`
|
||||
Prompt string `json:"prompt"`
|
||||
Options []string `json:"options"`
|
||||
MultiSelect bool `json:"multiSelect"`
|
||||
Header string `json:"header"`
|
||||
}
|
||||
|
||||
// ClarificationAnswerDto is the operator's answer to one question.
|
||||
type ClarificationAnswerDto struct {
|
||||
QuestionID string `json:"questionId"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// StatsDto mirrors the server's MetricsReport: a session's derived metrics
|
||||
@@ -291,7 +312,8 @@ func (m ServerMessage) IsEventBearing() bool {
|
||||
TypeInferenceFailed, TypeInferenceRetry,
|
||||
TypeToolStarted, TypeToolCompleted, TypeToolFailed, TypeToolRejected,
|
||||
TypeToolAssessed,
|
||||
TypeApprovalRequired, TypeApprovalResolved, TypeWorkspaceBound, TypeArtifactCreated,
|
||||
TypeApprovalRequired, TypeApprovalResolved, TypeClarification,
|
||||
TypeWorkspaceBound, TypeArtifactCreated,
|
||||
TypeArtifactValid, TypePlanLocked,
|
||||
TypeRouterNarration:
|
||||
return true
|
||||
@@ -362,6 +384,17 @@ func ApprovalResponse(requestID, decision string, steeringNote *string) []byte {
|
||||
})
|
||||
}
|
||||
|
||||
// ClarificationResponse answers a stage's open questions; the parked stage re-runs
|
||||
// with the answers in context.
|
||||
func ClarificationResponse(sessionID, stageID, requestID string, answers []ClarificationAnswerDto) []byte {
|
||||
return encode("ClarificationResponse", map[string]any{
|
||||
"sessionId": sessionID,
|
||||
"stageId": stageID,
|
||||
"requestId": requestID,
|
||||
"answers": answers,
|
||||
})
|
||||
}
|
||||
|
||||
// Ping is a keepalive.
|
||||
func Ping(ts int64) []byte {
|
||||
return encode("Ping", map[string]any{"timestamp": ts})
|
||||
|
||||
Reference in New Issue
Block a user