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
|
StateInSession
|
||||||
StateApproval
|
StateApproval
|
||||||
StateClarification
|
StateClarification
|
||||||
|
StateWorkflowPropose
|
||||||
)
|
)
|
||||||
|
|
||||||
// InputMode toggles what a submitted line targets.
|
// InputMode toggles what a submitted line targets.
|
||||||
@@ -125,6 +126,23 @@ type Clarification struct {
|
|||||||
Questions []ClarQuestion
|
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.
|
// Session is the UI's view of one server session.
|
||||||
type Session struct {
|
type Session struct {
|
||||||
ID string
|
ID string
|
||||||
@@ -141,6 +159,7 @@ type Session struct {
|
|||||||
Events []EventEntry
|
Events []EventEntry
|
||||||
Pending *Approval
|
Pending *Approval
|
||||||
Clar *Clarification // open questions awaiting answers (clarification view)
|
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)
|
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
|
clarText []string // per-question free-text / custom answer
|
||||||
clarTyping bool // typing into the custom buffer for the focused question
|
clarTyping bool // typing into the custom buffer for the focused question
|
||||||
clarDismissed bool // peeked away from the form (it can be reopened)
|
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.
|
// NewModel builds the initial idle state.
|
||||||
@@ -290,6 +315,10 @@ func (m Model) displayState() DisplayState {
|
|||||||
if s != nil && s.Clar != nil && !m.clarDismissed {
|
if s != nil && s.Clar != nil && !m.clarDismissed {
|
||||||
return StateClarification
|
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 {
|
if m.approvalDismissed {
|
||||||
return StateInSession
|
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 {
|
if s := m.session(msg.SessionID); s != nil {
|
||||||
s.Active = false
|
s.Active = false
|
||||||
s.Clar = nil
|
s.Clar = nil
|
||||||
|
s.Propose = nil
|
||||||
}
|
}
|
||||||
case protocol.TypeSessionFailed:
|
case protocol.TypeSessionFailed:
|
||||||
m.touch(msg.SessionID, "FAILED")
|
m.touch(msg.SessionID, "FAILED")
|
||||||
if s := m.session(msg.SessionID); s != nil {
|
if s := m.session(msg.SessionID); s != nil {
|
||||||
s.Active = false
|
s.Active = false
|
||||||
s.Clar = nil
|
s.Clar = nil
|
||||||
|
s.Propose = nil
|
||||||
}
|
}
|
||||||
case protocol.TypeStageStarted:
|
case protocol.TypeStageStarted:
|
||||||
if s := m.session(msg.SessionID); s != nil {
|
if s := m.session(msg.SessionID); s != nil {
|
||||||
@@ -246,6 +248,8 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
|||||||
m.onApprovalRequired(msg)
|
m.onApprovalRequired(msg)
|
||||||
case protocol.TypeClarification:
|
case protocol.TypeClarification:
|
||||||
m.onClarificationRequired(msg)
|
m.onClarificationRequired(msg)
|
||||||
|
case protocol.TypeWorkflowProposed:
|
||||||
|
m.onWorkflowProposed(msg)
|
||||||
case protocol.TypeApprovalResolved:
|
case protocol.TypeApprovalResolved:
|
||||||
if s := m.session(msg.SessionID); s != nil {
|
if s := m.session(msg.SessionID); s != nil {
|
||||||
s.Pending = 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) {
|
func (m *Model) onSnapshot(msg protocol.ServerMessage) {
|
||||||
var pending *Approval
|
var pending *Approval
|
||||||
if len(msg.PendingAppr) > 0 {
|
if len(msg.PendingAppr) > 0 {
|
||||||
|
|||||||
@@ -108,6 +108,9 @@ func (m Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.clarEnsureState()
|
m.clarEnsureState()
|
||||||
return m.handleClarificationKey(k)
|
return m.handleClarificationKey(k)
|
||||||
}
|
}
|
||||||
|
if m.displayState() == StateWorkflowPropose {
|
||||||
|
return m.handleProposeKey(k)
|
||||||
|
}
|
||||||
if m.editMode == ModeInsert {
|
if m.editMode == ModeInsert {
|
||||||
return m.handleInsertKey(k)
|
return m.handleInsertKey(k)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ func (m Model) View() string {
|
|||||||
if m.displayState() == StateClarification {
|
if m.displayState() == StateClarification {
|
||||||
return m.center(m.clarificationModal())
|
return m.center(m.clarificationModal())
|
||||||
}
|
}
|
||||||
|
if m.displayState() == StateWorkflowPropose {
|
||||||
|
return m.center(m.proposeModal())
|
||||||
|
}
|
||||||
if m.overlay != OverlayNone {
|
if m.overlay != OverlayNone {
|
||||||
return m.renderOverlay(base)
|
return m.renderOverlay(base)
|
||||||
}
|
}
|
||||||
@@ -224,6 +227,9 @@ func (m Model) renderFooter() string {
|
|||||||
case m.displayState() == StateClarification:
|
case m.displayState() == StateClarification:
|
||||||
// The question form owns its keys; the footer mirrors the essentials.
|
// 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")}
|
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:
|
case m.displayState() == StateIdle:
|
||||||
if nrw {
|
if nrw {
|
||||||
hints = []string{hint("i", "name"), hint("/", "filter"), hint("w", "wf"), hint("p", "cmds"), hint("q", "quit")}
|
hints = []string{hint("i", "name"), hint("/", "filter"), hint("w", "wf"), hint("p", "cmds"), hint("q", "quit")}
|
||||||
|
|||||||
@@ -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",
|
name: "clarification.required carries structured questions",
|
||||||
json: `{"type":"clarification.required","sessionId":"s1","requestId":"req-1","stageId":"analyst",` +
|
json: `{"type":"clarification.required","sessionId":"s1","requestId":"req-1","stageId":"analyst",` +
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const (
|
|||||||
TypeApprovalRequired = "approval.required"
|
TypeApprovalRequired = "approval.required"
|
||||||
TypeApprovalResolved = "approval.resolved"
|
TypeApprovalResolved = "approval.resolved"
|
||||||
TypeClarification = "clarification.required"
|
TypeClarification = "clarification.required"
|
||||||
|
TypeWorkflowProposed = "workflow.proposed"
|
||||||
TypeWorkspaceBound = "session.workspace_bound"
|
TypeWorkspaceBound = "session.workspace_bound"
|
||||||
TypeStageManifest = "stage.tool_manifest"
|
TypeStageManifest = "stage.tool_manifest"
|
||||||
TypeProviderStatus = "provider.status_changed"
|
TypeProviderStatus = "provider.status_changed"
|
||||||
@@ -139,6 +140,19 @@ type ServerMessage struct {
|
|||||||
|
|
||||||
// clarification.required — open questions a stage raised for the operator
|
// clarification.required — open questions a stage raised for the operator
|
||||||
Questions []ClarificationQuestionDto `json:"questions"`
|
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 ->
|
// ClarificationQuestionDto is one open question from a stage. Empty Options ->
|
||||||
@@ -313,6 +327,7 @@ func (m ServerMessage) IsEventBearing() bool {
|
|||||||
TypeToolStarted, TypeToolCompleted, TypeToolFailed, TypeToolRejected,
|
TypeToolStarted, TypeToolCompleted, TypeToolFailed, TypeToolRejected,
|
||||||
TypeToolAssessed,
|
TypeToolAssessed,
|
||||||
TypeApprovalRequired, TypeApprovalResolved, TypeClarification,
|
TypeApprovalRequired, TypeApprovalResolved, TypeClarification,
|
||||||
|
TypeWorkflowProposed,
|
||||||
TypeWorkspaceBound, TypeArtifactCreated,
|
TypeWorkspaceBound, TypeArtifactCreated,
|
||||||
TypeArtifactValid, TypePlanLocked,
|
TypeArtifactValid, TypePlanLocked,
|
||||||
TypeRouterNarration:
|
TypeRouterNarration:
|
||||||
|
|||||||
@@ -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.
|
// Run drives the reconnect loop until ctx is cancelled.
|
||||||
func (c *Client) Run(ctx context.Context) {
|
func (c *Client) Run(ctx context.Context) {
|
||||||
backoff := initialBackoff
|
backoff := initialBackoff
|
||||||
|
|||||||
Reference in New Issue
Block a user