diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index 0f81b202..763e3d8f 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -20,6 +20,7 @@ type InputMode int const ( ModeRouter InputMode = iota // chat / steering input ModeFilter // session-list filter + ModeIntent // freeform request for a workflow being started ) // EditMode is the vim-style modality: Normal = bare-key commands, Insert = typing. @@ -146,6 +147,8 @@ type Model struct { workflows []Workflow wfIndex int // -1 = not in workflow picker wfVisible bool + wfPendingID string // workflow chosen, awaiting an intent line before StartSession + wfPendingName string bgUpdates int // input diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 45359efe..f83dadc1 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -243,6 +243,12 @@ func (m Model) handleInsertKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { if m.inputMode == ModeFilter { m.inputMode = ModeRouter } + if m.inputMode == ModeIntent { + m.wfPendingID = "" + m.wfPendingName = "" + m.clearInput() + m.inputMode = ModeRouter + } case tea.KeyEnter: return m.submit() case tea.KeyBackspace: @@ -275,6 +281,19 @@ func (m Model) handleInsertKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +// beginIntent stashes the chosen workflow and switches to a text-entry prompt for the +// freeform request. Submitting sends StartSession(id, intent); an empty line starts with no +// intent (fixed-task workflows). Esc cancels. +func (m *Model) beginIntent(wf Workflow) { + m.wfPendingID = wf.ID + m.wfPendingName = wf.ID + m.wfVisible = false + m.wfIndex = -1 + m.clearInput() + m.editMode = ModeInsert + m.inputMode = ModeIntent +} + func (m *Model) enterInsert(mode InputMode) { m.editMode = ModeInsert m.inputMode = mode @@ -296,11 +315,7 @@ func (m Model) normalEnter() (tea.Model, tea.Cmd) { return m, nil } if m.wfVisible && m.wfIndex >= 0 && m.wfIndex < len(m.workflows) { - wf := m.workflows[m.wfIndex] - m.client.Send(protocol.StartSession(wf.ID)) - m.wfVisible = false - m.wfIndex = -1 - m.pendingWorkflowFocus = true + m.beginIntent(m.workflows[m.wfIndex]) return m, nil } if m.selectedID != "" { @@ -537,12 +552,21 @@ func (m Model) submit() (tea.Model, tea.Cmd) { switch ds { case StateIdle: - // Workflow picker selection. - if m.wfIndex >= 0 && m.wfIndex < len(m.workflows) { - wf := m.workflows[m.wfIndex] - m.wfIndex = -1 - m.client.Send(protocol.StartSession(wf.ID)) + // Intent line for a workflow being started. + if m.wfPendingID != "" { + id := m.wfPendingID + m.client.Send(protocol.StartSession(id, text)) + m.wfPendingID = "" + m.wfPendingName = "" m.clearInput() + m.inputMode = ModeRouter + m.editMode = ModeNormal + m.pendingWorkflowFocus = true + return m, nil + } + // Workflow picker selection → prompt for the request first. + if m.wfIndex >= 0 && m.wfIndex < len(m.workflows) { + m.beginIntent(m.workflows[m.wfIndex]) return m, nil } // Entering an already-selected session (blank submit). diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index 19bc19e9..0dd4888f 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -208,6 +208,8 @@ func (m Model) renderInput() string { placeholder := "Ask anything…" switch { + case m.inputMode == ModeIntent: + placeholder = "Describe the task for " + m.wfPendingName + " (Enter to start, empty = none)…" case m.inputMode == ModeFilter: placeholder = "Filter sessions…" case m.displayState() == StateIdle: @@ -241,6 +243,9 @@ func (m Model) renderInput() string { if m.inputMode == ModeFilter { mode = "filter" } + if m.inputMode == ModeIntent { + mode = "task" + } sub := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(name) if m.selectedID != "" { sub += t.span(" · ", t.P.Faint) + diff --git a/apps/tui-go/internal/protocol/protocol.go b/apps/tui-go/internal/protocol/protocol.go index c5639fd1..ed73c62f 100644 --- a/apps/tui-go/internal/protocol/protocol.go +++ b/apps/tui-go/internal/protocol/protocol.go @@ -213,9 +213,14 @@ func ChatInput(sessionID, text, mode string) []byte { return encode("ChatInput", map[string]any{"sessionId": sessionID, "text": text, "mode": mode}) } -// StartSession launches a workflow-backed session. -func StartSession(workflowID string) []byte { - return encode("StartSession", map[string]any{"workflowId": workflowID, "config": nil}) +// StartSession launches a workflow-backed session. input is the optional freeform request +// seeded into the decision journal; pass "" for fixed-task workflows (e.g. healthcheck). +func StartSession(workflowID, input string) []byte { + msg := map[string]any{"workflowId": workflowID, "config": nil} + if input != "" { + msg["input"] = input + } + return encode("StartSession", msg) } // CancelSession cancels a running session. diff --git a/apps/tui-go/internal/protocol/start_session_test.go b/apps/tui-go/internal/protocol/start_session_test.go new file mode 100644 index 00000000..d7e4f6a8 --- /dev/null +++ b/apps/tui-go/internal/protocol/start_session_test.go @@ -0,0 +1,32 @@ +package protocol + +import ( + "encoding/json" + "testing" +) + +func decodeFrame(t *testing.T, b []byte) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return m +} + +func TestStartSessionOmitsEmptyInput(t *testing.T) { + m := decodeFrame(t, StartSession("healthcheck", "")) + if m["workflowId"] != "healthcheck" { + t.Fatalf("unexpected frame: %+v", m) + } + if _, present := m["input"]; present { + t.Fatalf("empty input must be omitted, got: %+v", m) + } +} + +func TestStartSessionCarriesIntent(t *testing.T) { + m := decodeFrame(t, StartSession("role_pipeline", "add an auth layer to the account service")) + if m["input"] != "add an auth layer to the account service" { + t.Fatalf("intent not carried: %+v", m) + } +}