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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
Reference in New Issue
Block a user