package app import ( "strings" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/correx/tui-go/internal/protocol" ) // proposecard.go renders and drives the workflow-propose view: the router's triage // suggestion of candidate workflows, presented as a single-select picker with a // manual-answer slot (like a choice panel). Picking a candidate launches that // workflow seeded with the original request; the custom slot continues the chat. // --- widget state --- func (m *Model) proposeInitState() { m.proposeCursor = 0 m.proposeText = "" m.proposeTyping = false m.proposeDismissed = false } // --- key handling --- func (m Model) handleProposeKey(k keyMsg) (tea.Model, tea.Cmd) { s := m.session(m.selectedID) if s == nil || s.Propose == nil { return m, nil } if m.proposeTyping { return m.handleProposeTyping(k) } n := len(s.Propose.Candidates) // index n == the custom slot switch k.Type { case keyEsc: m.proposeDismissed = true case keyUp: m.proposeCursor = clampInt(m.proposeCursor-1, 0, n) case keyDown: m.proposeCursor = clampInt(m.proposeCursor+1, 0, n) case keyEnter, keySpace: return m.proposeSubmit() case keyRunes: switch string(k.Runes) { case "k": m.proposeCursor = clampInt(m.proposeCursor-1, 0, n) case "j": m.proposeCursor = clampInt(m.proposeCursor+1, 0, n) case "e": m.proposeCursor = n // jump to the custom slot and start typing m.proposeTyping = true } } return m, nil } func (m Model) handleProposeTyping(k keyMsg) (tea.Model, tea.Cmd) { switch k.Type { case keyEsc: m.proposeTyping = false case keyEnter: return m.proposeSubmit() // commit the custom answer and send it as chat case keyBackspace: if len(m.proposeText) > 0 { m.proposeText = m.proposeText[:len(m.proposeText)-1] } case keyRunes, keySpace: m.proposeText += string(k.Runes) } return m, nil } // proposeSubmit acts on the focused row: a candidate launches its workflow (seeded // with the original request); the custom slot continues the conversation as a chat // turn. An empty custom slot just drops into typing rather than submitting nothing. func (m Model) proposeSubmit() (tea.Model, tea.Cmd) { s := m.session(m.selectedID) if s == nil || s.Propose == nil { return m, nil } p := s.Propose if m.proposeCursor >= len(p.Candidates) { text := strings.TrimSpace(m.proposeText) if text == "" { m.proposeTyping = true return m, nil } m.client.Send(protocol.ChatInput(p.SessionID, text, "CHAT")) s.Propose = nil m.proposeInitState() return m, nil } pick := p.Candidates[m.proposeCursor] m.client.Send(protocol.StartSession(pick.WorkflowID, p.OriginalRequest)) m.pendingWorkflowFocus = true // focus the launched session when it announces s.Propose = nil m.proposeInitState() return m, nil } // --- rendering --- func (m Model) proposeModal() string { t := m.theme w := m.modalWidth() s := m.session(m.selectedID) if s == nil || s.Propose == nil { return "" } p := s.Propose var b strings.Builder b.WriteString(m.titleLine("workflow suggestion") + "\n") if p.OriginalRequest != "" { b.WriteString(mbg(t, " for: "+p.OriginalRequest, t.P.Faint) + "\n") } if p.Prompt != "" { b.WriteString(mbg(t, " "+p.Prompt, t.P.Fg) + "\n") } b.WriteString("\n") contentW := w - 6 for i, c := range p.Candidates { b.WriteString(m.proposeRow(c, i == m.proposeCursor, contentW) + "\n") } b.WriteString(m.proposeCustomRow(m.proposeCursor >= len(p.Candidates), contentW) + "\n") b.WriteString("\n" + modalHints(t, [][2]string{ {"↑↓", "choose"}, {"enter", "launch"}, {"e", "custom"}, {"esc", "later"}, })) return t.Overlay.Width(w).Render(b.String()) } // proposeRow renders one candidate workflow: a focus marker, the [workflow-id] chip, // and its reason. func (m Model) proposeRow(c ProposeCandidate, focused bool, w int) string { t := m.theme marker := mbg(t, " ", t.P.BgPanel) reasonFg := t.P.Dim if focused { marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▸ ") reasonFg = t.P.Fg } line := marker + lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Render("["+c.WorkflowID+"]") if c.Reason != "" { line += mbg(t, " "+c.Reason, reasonFg) } return clip(line, w) } // proposeCustomRow renders the manual-answer slot: a prompt when empty, the typed // text (with a caret while editing) otherwise. func (m Model) proposeCustomRow(focused bool, w int) string { t := m.theme marker := mbg(t, " ", t.P.BgPanel) fg := t.P.Dim if focused { marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▸ ") fg = t.P.Fg } label := "✎ something else…" switch { case m.proposeTyping: label = "✎ " + m.proposeText + "▏" case strings.TrimSpace(m.proposeText) != "": label = "✎ " + m.proposeText } return clip(marker+lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(label), w) }