feat(tui): workflow-start intent input box wired to StartSession.input

This commit is contained in:
2026-06-04 02:24:41 +04:00
parent fe561ada09
commit 638f57709d
5 changed files with 82 additions and 13 deletions
+3
View File
@@ -20,6 +20,7 @@ type InputMode int
const ( const (
ModeRouter InputMode = iota // chat / steering input ModeRouter InputMode = iota // chat / steering input
ModeFilter // session-list filter ModeFilter // session-list filter
ModeIntent // freeform request for a workflow being started
) )
// EditMode is the vim-style modality: Normal = bare-key commands, Insert = typing. // EditMode is the vim-style modality: Normal = bare-key commands, Insert = typing.
@@ -146,6 +147,8 @@ type Model struct {
workflows []Workflow workflows []Workflow
wfIndex int // -1 = not in workflow picker wfIndex int // -1 = not in workflow picker
wfVisible bool wfVisible bool
wfPendingID string // workflow chosen, awaiting an intent line before StartSession
wfPendingName string
bgUpdates int bgUpdates int
// input // input
+34 -10
View File
@@ -243,6 +243,12 @@ func (m Model) handleInsertKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.inputMode == ModeFilter { if m.inputMode == ModeFilter {
m.inputMode = ModeRouter m.inputMode = ModeRouter
} }
if m.inputMode == ModeIntent {
m.wfPendingID = ""
m.wfPendingName = ""
m.clearInput()
m.inputMode = ModeRouter
}
case tea.KeyEnter: case tea.KeyEnter:
return m.submit() return m.submit()
case tea.KeyBackspace: case tea.KeyBackspace:
@@ -275,6 +281,19 @@ func (m Model) handleInsertKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil 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) { func (m *Model) enterInsert(mode InputMode) {
m.editMode = ModeInsert m.editMode = ModeInsert
m.inputMode = mode m.inputMode = mode
@@ -296,11 +315,7 @@ func (m Model) normalEnter() (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
if m.wfVisible && m.wfIndex >= 0 && m.wfIndex < len(m.workflows) { if m.wfVisible && m.wfIndex >= 0 && m.wfIndex < len(m.workflows) {
wf := m.workflows[m.wfIndex] m.beginIntent(m.workflows[m.wfIndex])
m.client.Send(protocol.StartSession(wf.ID))
m.wfVisible = false
m.wfIndex = -1
m.pendingWorkflowFocus = true
return m, nil return m, nil
} }
if m.selectedID != "" { if m.selectedID != "" {
@@ -537,12 +552,21 @@ func (m Model) submit() (tea.Model, tea.Cmd) {
switch ds { switch ds {
case StateIdle: case StateIdle:
// Workflow picker selection. // Intent line for a workflow being started.
if m.wfIndex >= 0 && m.wfIndex < len(m.workflows) { if m.wfPendingID != "" {
wf := m.workflows[m.wfIndex] id := m.wfPendingID
m.wfIndex = -1 m.client.Send(protocol.StartSession(id, text))
m.client.Send(protocol.StartSession(wf.ID)) m.wfPendingID = ""
m.wfPendingName = ""
m.clearInput() 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 return m, nil
} }
// Entering an already-selected session (blank submit). // Entering an already-selected session (blank submit).
+5
View File
@@ -208,6 +208,8 @@ func (m Model) renderInput() string {
placeholder := "Ask anything…" placeholder := "Ask anything…"
switch { switch {
case m.inputMode == ModeIntent:
placeholder = "Describe the task for " + m.wfPendingName + " (Enter to start, empty = none)…"
case m.inputMode == ModeFilter: case m.inputMode == ModeFilter:
placeholder = "Filter sessions…" placeholder = "Filter sessions…"
case m.displayState() == StateIdle: case m.displayState() == StateIdle:
@@ -241,6 +243,9 @@ func (m Model) renderInput() string {
if m.inputMode == ModeFilter { if m.inputMode == ModeFilter {
mode = "filter" mode = "filter"
} }
if m.inputMode == ModeIntent {
mode = "task"
}
sub := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(name) sub := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(name)
if m.selectedID != "" { if m.selectedID != "" {
sub += t.span(" · ", t.P.Faint) + sub += t.span(" · ", t.P.Faint) +
+8 -3
View File
@@ -213,9 +213,14 @@ func ChatInput(sessionID, text, mode string) []byte {
return encode("ChatInput", map[string]any{"sessionId": sessionID, "text": text, "mode": mode}) return encode("ChatInput", map[string]any{"sessionId": sessionID, "text": text, "mode": mode})
} }
// StartSession launches a workflow-backed session. // StartSession launches a workflow-backed session. input is the optional freeform request
func StartSession(workflowID string) []byte { // seeded into the decision journal; pass "" for fixed-task workflows (e.g. healthcheck).
return encode("StartSession", map[string]any{"workflowId": workflowID, "config": nil}) 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. // CancelSession cancels a running session.
@@ -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)
}
}