feat(tui-go): workflow-propose choice panel

Render a workflow.proposed frame as a single-select picker with a manual-answer
slot: ↑↓/jk to choose, enter to launch, e for custom, esc to peek away. Picking
a candidate sends StartSession (and focuses the launched session); the custom
slot continues the conversation via ChatInput. Reuses the modal helpers from the
clarification view.
This commit is contained in:
2026-06-14 13:29:34 +04:00
parent b62b6e09a1
commit b563d9841d
9 changed files with 420 additions and 0 deletions
+29
View File
@@ -13,6 +13,7 @@ const (
StateInSession
StateApproval
StateClarification
StateWorkflowPropose
)
// InputMode toggles what a submitted line targets.
@@ -125,6 +126,23 @@ type Clarification struct {
Questions []ClarQuestion
}
// ProposeCandidate is one workflow the router suggests (mirrors the wire DTO).
type ProposeCandidate struct {
WorkflowID string
Reason string
}
// Proposal is the router's triage suggestion of candidate workflows for a chat
// request, rendered as a choice panel with a manual-answer slot. Picking a candidate
// launches that workflow; the custom slot continues the conversation.
type Proposal struct {
ProposalID string
SessionID string
Prompt string
OriginalRequest string
Candidates []ProposeCandidate
}
// Session is the UI's view of one server session.
type Session struct {
ID string
@@ -141,6 +159,7 @@ type Session struct {
Events []EventEntry
Pending *Approval
Clar *Clarification // open questions awaiting answers (clarification view)
Propose *Proposal // router workflow suggestion awaiting a pick (propose view)
Active bool // an inference/tool is in flight (drives the spinner)
}
@@ -256,6 +275,12 @@ type Model struct {
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)
// workflow-propose view (the router's candidate-workflow picker)
proposeCursor int // cursor over candidates (== len(candidates) → custom slot)
proposeText string // free-text answer typed into the custom slot
proposeTyping bool // typing into the custom buffer
proposeDismissed bool // peeked away from the picker (it can be reopened)
}
// NewModel builds the initial idle state.
@@ -290,6 +315,10 @@ func (m Model) displayState() DisplayState {
if s != nil && s.Clar != nil && !m.clarDismissed {
return StateClarification
}
// A router workflow proposal is the operator's call to make before chatting on.
if s != nil && s.Propose != nil && !m.proposeDismissed {
return StateWorkflowPropose
}
if m.approvalDismissed {
return StateInSession
}
+171
View File
@@ -0,0 +1,171 @@
package app
import (
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"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 tea.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 tea.KeyEsc:
m.proposeDismissed = true
case tea.KeyUp:
m.proposeCursor = clampInt(m.proposeCursor-1, 0, n)
case tea.KeyDown:
m.proposeCursor = clampInt(m.proposeCursor+1, 0, n)
case tea.KeyEnter, tea.KeySpace:
return m.proposeSubmit()
case tea.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 tea.KeyMsg) (tea.Model, tea.Cmd) {
switch k.Type {
case tea.KeyEsc:
m.proposeTyping = false
case tea.KeyEnter:
return m.proposeSubmit() // commit the custom answer and send it as chat
case tea.KeyBackspace:
if len(m.proposeText) > 0 {
m.proposeText = m.proposeText[:len(m.proposeText)-1]
}
case tea.KeyRunes, tea.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)
}
@@ -0,0 +1,142 @@
package app
import (
"encoding/json"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/correx/tui-go/internal/protocol"
"github.com/correx/tui-go/internal/ws"
)
func proposeModel() (Model, *ws.Client) {
client := ws.New("", 0) // unconnected; Send just buffers, Drain reads it back
m := NewModel(client)
m.width, m.height = 120, 40
m.theme = NewTheme(SoftBlue)
m.selectedID = "s1"
m.sessionEntered = true
m.applyServer(protocol.ServerMessage{
Type: protocol.TypeWorkflowProposed,
SessionID: "s1",
ProposalID: "prop-1",
Prompt: "Run one of these?",
OriginalRequest: "find papers on event sourcing",
Candidates: []protocol.ProposedWorkflowDto{
{WorkflowID: "research", Reason: "gather + report"},
{WorkflowID: "role_pipeline", Reason: "full build"},
},
})
return m, client
}
func TestProposeEntersStateAndRenders(t *testing.T) {
m, _ := proposeModel()
if m.displayState() != StateWorkflowPropose {
t.Fatalf("want StateWorkflowPropose, got %v", m.displayState())
}
out := m.proposeModal()
for _, want := range []string{"workflow suggestion", "Run one of these?", "research", "gather + report", "role_pipeline", "something else"} {
if !strings.Contains(out, want) {
t.Fatalf("modal missing %q:\n%s", want, out)
}
}
}
func TestProposePickLaunchesChosenWorkflow(t *testing.T) {
m, client := proposeModel()
// move cursor to the second candidate (role_pipeline) and launch it
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyDown})
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEnter})
if s := m.session("s1"); s == nil || s.Propose != nil {
t.Fatalf("expected proposal cleared after launch")
}
if !m.pendingWorkflowFocus {
t.Fatalf("expected pendingWorkflowFocus set so the launched session is focused")
}
wf, input := decodeStart(t, client)
if wf != "role_pipeline" {
t.Fatalf("launched wrong workflow: %q", wf)
}
if input != "find papers on event sourcing" {
t.Fatalf("workflow not seeded with original request: %q", input)
}
}
func TestProposeFirstCandidateIsDefault(t *testing.T) {
m, client := proposeModel()
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEnter}) // no nav → first candidate
wf, _ := decodeStart(t, client)
if wf != "research" {
t.Fatalf("expected first candidate launched by default, got %q", wf)
}
}
func TestProposeCustomAnswerContinuesChat(t *testing.T) {
m, client := proposeModel()
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")})
if !m.proposeTyping {
t.Fatalf("expected typing mode after 'e'")
}
for _, r := range "do it manually" {
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
}
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEnter})
if s := m.session("s1"); s == nil || s.Propose != nil {
t.Fatalf("expected proposal cleared after custom answer")
}
if m.pendingWorkflowFocus {
t.Fatalf("a custom answer must not launch a workflow")
}
frames := client.Drain()
if len(frames) != 1 {
t.Fatalf("expected exactly one frame, got %d", len(frames))
}
var raw map[string]any
if err := json.Unmarshal(frames[0], &raw); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if raw["type"] != "com.correx.apps.server.protocol.ClientMessage.ChatInput" {
t.Fatalf("expected ChatInput, got %v", raw["type"])
}
if raw["text"] != "do it manually" {
t.Fatalf("custom text not sent: %v", raw["text"])
}
}
func TestProposeEscDismisses(t *testing.T) {
m, _ := proposeModel()
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEsc})
if m.displayState() != StateInSession {
t.Fatalf("expected StateInSession after esc, got %v", m.displayState())
}
if s := m.session("s1"); s == nil || s.Propose == nil {
t.Fatalf("esc should peek away, not discard the proposal")
}
}
func applyProposeKey(m Model, k tea.KeyMsg) Model {
updated, _ := m.handleProposeKey(k)
return updated.(Model)
}
func decodeStart(t *testing.T, client *ws.Client) (workflowID, input string) {
t.Helper()
frames := client.Drain()
if len(frames) != 1 {
t.Fatalf("expected exactly one frame, got %d", len(frames))
}
var raw map[string]any
if err := json.Unmarshal(frames[0], &raw); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if raw["type"] != "com.correx.apps.server.protocol.ClientMessage.StartSession" {
t.Fatalf("expected StartSession, got %v", raw["type"])
}
wf, _ := raw["workflowId"].(string)
in, _ := raw["input"].(string)
return wf, in
}
+21
View File
@@ -133,12 +133,14 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
if s := m.session(msg.SessionID); s != nil {
s.Active = false
s.Clar = nil
s.Propose = nil
}
case protocol.TypeSessionFailed:
m.touch(msg.SessionID, "FAILED")
if s := m.session(msg.SessionID); s != nil {
s.Active = false
s.Clar = nil
s.Propose = nil
}
case protocol.TypeStageStarted:
if s := m.session(msg.SessionID); s != nil {
@@ -246,6 +248,8 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
m.onApprovalRequired(msg)
case protocol.TypeClarification:
m.onClarificationRequired(msg)
case protocol.TypeWorkflowProposed:
m.onWorkflowProposed(msg)
case protocol.TypeApprovalResolved:
if s := m.session(msg.SessionID); s != nil {
s.Pending = nil
@@ -424,6 +428,23 @@ func (m *Model) onClarificationRequired(msg protocol.ServerMessage) {
}
}
func (m *Model) onWorkflowProposed(msg protocol.ServerMessage) {
cands := make([]ProposeCandidate, 0, len(msg.Candidates))
for _, c := range msg.Candidates {
cands = append(cands, ProposeCandidate{WorkflowID: c.WorkflowID, Reason: c.Reason})
}
p := &Proposal{
ProposalID: msg.ProposalID, SessionID: msg.SessionID,
Prompt: msg.Prompt, OriginalRequest: msg.OriginalRequest, Candidates: cands,
}
if s := m.session(msg.SessionID); s != nil {
s.Propose = p
}
if msg.SessionID == m.selectedID {
m.proposeInitState()
}
}
func (m *Model) onSnapshot(msg protocol.ServerMessage) {
var pending *Approval
if len(msg.PendingAppr) > 0 {
+3
View File
@@ -108,6 +108,9 @@ func (m Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
m.clarEnsureState()
return m.handleClarificationKey(k)
}
if m.displayState() == StateWorkflowPropose {
return m.handleProposeKey(k)
}
if m.editMode == ModeInsert {
return m.handleInsertKey(k)
}
+6
View File
@@ -58,6 +58,9 @@ func (m Model) View() string {
if m.displayState() == StateClarification {
return m.center(m.clarificationModal())
}
if m.displayState() == StateWorkflowPropose {
return m.center(m.proposeModal())
}
if m.overlay != OverlayNone {
return m.renderOverlay(base)
}
@@ -224,6 +227,9 @@ func (m Model) renderFooter() string {
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() == StateWorkflowPropose:
// The picker owns its keys; the footer mirrors the essentials.
hints = []string{hint("↑↓", "choose"), hint("enter", "launch"), hint("e", "custom"), 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")}