ai-agent/internal/tui/planform.go
admin 8dc496b626
Some checks failed
CI / test (push) Has been cancelled
Release / release (push) Failing after 4m36s
first commit
2026-03-08 15:40:34 +07:00

259 lines
7.1 KiB
Go

package tui
import (
"fmt"
"strings"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// PlanFormField represents a single field in the plan form.
type PlanFormField struct {
Label string
Kind string // "text" or "select"
Value string // current value (for select, set from Options[OptionIndex])
Options []string // for "select" kind
OptionIndex int // for "select" kind
Input textinput.Model
}
// PlanFormState holds state for the plan form overlay.
type PlanFormState struct {
Fields []PlanFormField
ActiveField int
}
// NewPlanFormState creates a plan form pre-filled with the user's task description.
func NewPlanFormState(task string) *PlanFormState {
taskInput := textinput.New()
taskInput.Placeholder = "Describe the task..."
taskInput.CharLimit = 512
taskInput.SetValue(task)
taskInput.Focus()
focusInput := textinput.New()
focusInput.Placeholder = "Any constraints or requirements? (optional)"
focusInput.CharLimit = 512
return &PlanFormState{
Fields: []PlanFormField{
{
Label: "Task",
Kind: "text",
Input: taskInput,
},
{
Label: "Scope",
Kind: "select",
Options: []string{"single file", "module", "project-wide"},
},
{
Label: "Focus (optional)",
Kind: "text",
Input: focusInput,
},
},
ActiveField: 0,
}
}
// AssemblePrompt builds the structured prompt from form fields.
func (pf *PlanFormState) AssemblePrompt() string {
task := pf.Fields[0].Input.Value()
scope := pf.Fields[1].Options[pf.Fields[1].OptionIndex]
focus := pf.Fields[2].Input.Value()
var b strings.Builder
b.WriteString("Plan the following task:\n")
b.WriteString(fmt.Sprintf("Task: %s\n", task))
b.WriteString(fmt.Sprintf("Scope: %s\n", scope))
if focus != "" {
b.WriteString(fmt.Sprintf("Focus: %s\n", focus))
}
b.WriteString("\nProvide a step-by-step plan.")
return b.String()
}
// updatePlanForm handles key events within the plan form overlay.
// Returns the updated model, any command, and whether the form was submitted or cancelled.
func (m *Model) updatePlanForm(msg tea.KeyPressMsg) (bool, bool) {
pf := m.planFormState
if pf == nil {
return false, false
}
field := &pf.Fields[pf.ActiveField]
switch {
case key.Matches(msg, m.keys.Cancel):
// Cancel
return false, true
case msg.Code == tea.KeyEnter:
if pf.ActiveField == len(pf.Fields)-1 {
// Submit
return true, false
}
// Advance to next field
m.advancePlanFormField(1)
return false, false
case msg.Code == tea.KeyTab:
if msg.Mod == tea.ModShift {
m.advancePlanFormField(-1)
} else {
m.advancePlanFormField(1)
}
return false, false
case msg.Code == tea.KeyUp:
if field.Kind == "select" {
if field.OptionIndex > 0 {
field.OptionIndex--
}
return false, false
}
case msg.Code == tea.KeyDown:
if field.Kind == "select" {
if field.OptionIndex < len(field.Options)-1 {
field.OptionIndex++
}
return false, false
}
case msg.Code == tea.KeyLeft:
if field.Kind == "select" {
if field.OptionIndex > 0 {
field.OptionIndex--
}
return false, false
}
case msg.Code == tea.KeyRight:
if field.Kind == "select" {
if field.OptionIndex < len(field.Options)-1 {
field.OptionIndex++
}
return false, false
}
}
// Forward other keys to active text field
if field.Kind == "text" {
field.Input, _ = field.Input.Update(msg)
}
return false, false
}
// advancePlanFormField moves to the next or previous field.
func (m *Model) advancePlanFormField(dir int) {
pf := m.planFormState
if pf == nil {
return
}
// Blur current field
current := &pf.Fields[pf.ActiveField]
if current.Kind == "text" {
current.Input.Blur()
}
pf.ActiveField += dir
if pf.ActiveField < 0 {
pf.ActiveField = 0
}
if pf.ActiveField >= len(pf.Fields) {
pf.ActiveField = len(pf.Fields) - 1
}
// Focus new field
next := &pf.Fields[pf.ActiveField]
if next.Kind == "text" {
next.Input.Focus()
}
}
// renderPlanForm renders the plan form overlay.
func (m *Model) renderPlanForm() string {
pf := m.planFormState
if pf == nil {
return ""
}
activeStyle := m.styles.FocusIndicator // Use focus indicator style for active fields
var b strings.Builder
b.WriteString(m.styles.OverlayTitle.Render("Plan Task"))
b.WriteString("\n\n")
for i, field := range pf.Fields {
isActive := i == pf.ActiveField
ls := m.styles.OverlayAccent
if isActive {
ls = activeStyle
}
b.WriteString(ls.Render(field.Label))
b.WriteString("\n")
switch field.Kind {
case "text":
if isActive {
b.WriteString(m.styles.FocusIndicator.Render("> ") + field.Input.View())
} else {
val := field.Input.Value()
if val == "" {
val = m.styles.OverlayDim.Render("(empty)")
}
b.WriteString(" " + m.styles.OverlayDim.Render(val))
}
case "select":
for j, opt := range field.Options {
selected := j == field.OptionIndex
prefix := " "
if selected && isActive {
prefix = m.styles.FocusIndicator.Render("▸ ")
} else if selected {
prefix = "● "
}
if selected && isActive {
b.WriteString(" " + activeStyle.Render(prefix+opt))
} else if selected {
b.WriteString(" " + prefix + opt)
} else {
b.WriteString(" " + m.styles.OverlayDim.Render(prefix+opt))
}
b.WriteString("\n")
}
}
b.WriteString("\n\n")
}
if pf.Fields[pf.ActiveField].Kind == "select" {
b.WriteString(m.styles.OverlayDim.Render("↑↓←→=select Tab/Enter=next Esc=cancel"))
} else {
b.WriteString(m.styles.OverlayDim.Render("Tab=next field Enter=submit Esc=cancel"))
}
maxW := 50
if m.width-8 > maxW {
maxW = m.width - 8
}
if maxW > 60 {
maxW = 60
}
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color(m.styles.OverlayBorder)).
Padding(1, 2).
Width(maxW)
return box.Render(b.String())
}