feat(tui): workflow-start intent input box wired to StartSession.input
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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) +
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user