diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index 95513537..f2b13f6f 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -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 } diff --git a/apps/tui-go/internal/app/proposecard.go b/apps/tui-go/internal/app/proposecard.go new file mode 100644 index 00000000..cbbe3b6c --- /dev/null +++ b/apps/tui-go/internal/app/proposecard.go @@ -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) +} diff --git a/apps/tui-go/internal/app/proposecard_test.go b/apps/tui-go/internal/app/proposecard_test.go new file mode 100644 index 00000000..cd8d9f7b --- /dev/null +++ b/apps/tui-go/internal/app/proposecard_test.go @@ -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 +} diff --git a/apps/tui-go/internal/app/server.go b/apps/tui-go/internal/app/server.go index 7545e4a5..94791a41 100644 --- a/apps/tui-go/internal/app/server.go +++ b/apps/tui-go/internal/app/server.go @@ -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 { diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 0b2bb9db..4375830b 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -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) } diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index 56870ee0..de013c8d 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -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")} diff --git a/apps/tui-go/internal/protocol/golden_test.go b/apps/tui-go/internal/protocol/golden_test.go index 082a9564..96ae408d 100644 --- a/apps/tui-go/internal/protocol/golden_test.go +++ b/apps/tui-go/internal/protocol/golden_test.go @@ -149,6 +149,25 @@ func TestDecodeGoldenServerFrames(t *testing.T) { } }, }, + { + name: "workflow.proposed carries candidate workflows", + json: `{"type":"workflow.proposed","sessionId":"s1","proposalId":"prop-1","prompt":"Run one?",` + + `"candidates":[{"workflowId":"research","reason":"gather + report"},{"workflowId":"role_pipeline","reason":""}],` + + `"originalRequest":"find papers","sequence":9,"sessionSequence":4}`, + wantType: TypeWorkflowProposed, + eventBear: true, + check: func(t *testing.T, m ServerMessage) { + if m.ProposalID != "prop-1" || m.Prompt != "Run one?" || m.OriginalRequest != "find papers" { + t.Fatalf("workflow.proposed fields: %+v", m) + } + if len(m.Candidates) != 2 { + t.Fatalf("workflow.proposed candidates: %+v", m.Candidates) + } + if m.Candidates[0].WorkflowID != "research" || m.Candidates[0].Reason != "gather + report" { + t.Fatalf("workflow.proposed candidate[0]: %+v", m.Candidates[0]) + } + }, + }, { name: "clarification.required carries structured questions", json: `{"type":"clarification.required","sessionId":"s1","requestId":"req-1","stageId":"analyst",` + diff --git a/apps/tui-go/internal/protocol/protocol.go b/apps/tui-go/internal/protocol/protocol.go index 606ca52d..e3cae822 100644 --- a/apps/tui-go/internal/protocol/protocol.go +++ b/apps/tui-go/internal/protocol/protocol.go @@ -35,6 +35,7 @@ const ( TypeApprovalRequired = "approval.required" TypeApprovalResolved = "approval.resolved" TypeClarification = "clarification.required" + TypeWorkflowProposed = "workflow.proposed" TypeWorkspaceBound = "session.workspace_bound" TypeStageManifest = "stage.tool_manifest" TypeProviderStatus = "provider.status_changed" @@ -139,6 +140,19 @@ type ServerMessage struct { // clarification.required — open questions a stage raised for the operator Questions []ClarificationQuestionDto `json:"questions"` + + // workflow.proposed — the router's triage suggestion of candidate workflows + ProposalID string `json:"proposalId"` + Prompt string `json:"prompt"` + OriginalRequest string `json:"originalRequest"` + Candidates []ProposedWorkflowDto `json:"candidates"` +} + +// ProposedWorkflowDto is one candidate workflow the router suggests, with a short +// reason. Mirrors the Kotlin ProposedWorkflow (core:events), the wire shape. +type ProposedWorkflowDto struct { + WorkflowID string `json:"workflowId"` + Reason string `json:"reason"` } // ClarificationQuestionDto is one open question from a stage. Empty Options -> @@ -313,6 +327,7 @@ func (m ServerMessage) IsEventBearing() bool { TypeToolStarted, TypeToolCompleted, TypeToolFailed, TypeToolRejected, TypeToolAssessed, TypeApprovalRequired, TypeApprovalResolved, TypeClarification, + TypeWorkflowProposed, TypeWorkspaceBound, TypeArtifactCreated, TypeArtifactValid, TypePlanLocked, TypeRouterNarration: diff --git a/apps/tui-go/internal/ws/client.go b/apps/tui-go/internal/ws/client.go index e2dec59e..7e59ccb6 100644 --- a/apps/tui-go/internal/ws/client.go +++ b/apps/tui-go/internal/ws/client.go @@ -82,6 +82,20 @@ func (c *Client) Send(frame []byte) { } } +// Drain returns all queued-but-unsent client frames. Test-only: meaningful before +// Run starts consuming the send channel, so a test can assert on what was Sent. +func (c *Client) Drain() [][]byte { + var out [][]byte + for { + select { + case f := <-c.send: + out = append(out, f) + default: + return out + } + } +} + // Run drives the reconnect loop until ctx is cancelled. func (c *Client) Run(ctx context.Context) { backoff := initialBackoff