package tui import ( "fmt" "os" "strings" "git.db.org.ai/dborg/internal/formatter" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) type wizardState int const ( stateForm wizardState = iota stateSearching stateResults stateGenerating stateDone stateError ) type SearchFunc func(firstName, lastName, city, state, age string) (map[string]interface{}, string, error) type ReportFunc func(sxKey string, selection int) (map[string]interface{}, error) type WizardModel struct { state wizardState inputs []textinput.Model focusIndex int people []Person cursor int offset int selectedIdx int sxKey string reportData map[string]interface{} err error searchFn SearchFunc reportFn ReportFunc windowWidth int windowHeight int jsonOutput bool } var ( focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) cursorStyle = focusedStyle noStyle = lipgloss.NewStyle() focusedButton = lipgloss.NewStyle(). Foreground(lipgloss.Color("229")). Background(lipgloss.Color("57")). Padding(0, 2) blurredButton = lipgloss.NewStyle(). Foreground(lipgloss.Color("243")). Padding(0, 2) wizardTitleStyle = lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("86")). MarginBottom(1) statusStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("241")). Italic(true) ) func NewWizardModel(searchFn SearchFunc, reportFn ReportFunc, jsonOutput bool) WizardModel { inputs := make([]textinput.Model, 5) inputs[0] = textinput.New() inputs[0].Placeholder = "John" inputs[0].Focus() inputs[0].CharLimit = 50 inputs[0].Width = 30 inputs[0].Prompt = "First Name: " inputs[0].PromptStyle = focusedStyle inputs[0].TextStyle = focusedStyle inputs[1] = textinput.New() inputs[1].Placeholder = "Doe" inputs[1].CharLimit = 50 inputs[1].Width = 30 inputs[1].Prompt = "Last Name: " inputs[1].PromptStyle = blurredStyle inputs[1].TextStyle = blurredStyle inputs[2] = textinput.New() inputs[2].Placeholder = "New York" inputs[2].CharLimit = 50 inputs[2].Width = 30 inputs[2].Prompt = "City: " inputs[2].PromptStyle = blurredStyle inputs[2].TextStyle = blurredStyle inputs[3] = textinput.New() inputs[3].Placeholder = "NY" inputs[3].CharLimit = 2 inputs[3].Width = 10 inputs[3].Prompt = "State: " inputs[3].PromptStyle = blurredStyle inputs[3].TextStyle = blurredStyle inputs[4] = textinput.New() inputs[4].Placeholder = "35" inputs[4].CharLimit = 3 inputs[4].Width = 10 inputs[4].Prompt = "Age: " inputs[4].PromptStyle = blurredStyle inputs[4].TextStyle = blurredStyle return WizardModel{ state: stateForm, inputs: inputs, focusIndex: 0, searchFn: searchFn, reportFn: reportFn, jsonOutput: jsonOutput, } } type searchResultMsg struct { data map[string]interface{} sxKey string err error } type reportResultMsg struct { data map[string]interface{} err error } func (m WizardModel) Init() tea.Cmd { return textinput.Blink } func (m WizardModel) doSearch() tea.Cmd { return func() tea.Msg { data, sxKey, err := m.searchFn( m.inputs[0].Value(), m.inputs[1].Value(), m.inputs[2].Value(), strings.ToUpper(m.inputs[3].Value()), m.inputs[4].Value(), ) if err != nil { return searchResultMsg{err: err} } return searchResultMsg{data: data, sxKey: sxKey} } } func (m WizardModel) doReport() tea.Cmd { return func() tea.Msg { data, err := m.reportFn(m.sxKey, m.selectedIdx+1) if err != nil { return reportResultMsg{err: err} } return reportResultMsg{data: data} } } func (m WizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.windowWidth = msg.Width m.windowHeight = msg.Height case tea.KeyMsg: switch m.state { case stateForm: return m.updateForm(msg) case stateResults: return m.updateResults(msg) case stateError: if msg.String() == "q" || msg.String() == "ctrl+c" || msg.String() == "enter" { return m, tea.Quit } case stateDone: return m, tea.Quit } case searchResultMsg: if msg.err != nil { m.state = stateError m.err = msg.err return m, nil } people, err := extractPeople(msg.data) if err != nil { m.state = stateError m.err = err return m, nil } if len(people) == 0 { m.state = stateError m.err = fmt.Errorf("no results found") return m, nil } m.people = people m.sxKey = msg.sxKey m.cursor = 0 m.offset = 0 m.state = stateResults return m, nil case reportResultMsg: if msg.err != nil { m.state = stateError m.err = msg.err return m, nil } m.reportData = msg.data m.state = stateDone return m, tea.Quit } return m, nil } func (m WizardModel) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c", "esc": return m, tea.Quit case "tab", "down": m.focusIndex++ if m.focusIndex > len(m.inputs) { m.focusIndex = 0 } return m.updateFocus(), nil case "shift+tab", "up": m.focusIndex-- if m.focusIndex < 0 { m.focusIndex = len(m.inputs) } return m.updateFocus(), nil case "enter": if m.focusIndex == len(m.inputs) { if m.inputs[0].Value() == "" || m.inputs[1].Value() == "" { m.err = fmt.Errorf("first name and last name are required") return m, nil } m.state = stateSearching m.err = nil return m, m.doSearch() } m.focusIndex++ if m.focusIndex > len(m.inputs) { m.focusIndex = len(m.inputs) } return m.updateFocus(), nil } cmd := m.updateInputs(msg) return m, cmd } func (m WizardModel) updateResults(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c", "q": return m, tea.Quit case "esc": m.state = stateForm m.focusIndex = 0 return m.updateFocus(), nil case "enter": m.selectedIdx = m.cursor m.state = stateGenerating return m, m.doReport() case "up", "k": if m.cursor > 0 { m.cursor-- if m.cursor < m.offset { m.offset = m.cursor } } case "down", "j": if m.cursor < len(m.people)-1 { m.cursor++ visibleHeight := m.resultsVisibleHeight() if m.cursor >= m.offset+visibleHeight { m.offset = m.cursor - visibleHeight + 1 } } case "home": m.cursor = 0 m.offset = 0 case "end": m.cursor = len(m.people) - 1 visibleHeight := m.resultsVisibleHeight() if len(m.people) > visibleHeight { m.offset = len(m.people) - visibleHeight } } return m, nil } func (m WizardModel) resultsVisibleHeight() int { if m.windowHeight > 0 { return (m.windowHeight - 10) / 6 } return 4 } func (m *WizardModel) updateFocus() WizardModel { for i := range m.inputs { if i == m.focusIndex { m.inputs[i].Focus() m.inputs[i].PromptStyle = focusedStyle m.inputs[i].TextStyle = focusedStyle } else { m.inputs[i].Blur() m.inputs[i].PromptStyle = blurredStyle m.inputs[i].TextStyle = blurredStyle } } return *m } func (m *WizardModel) updateInputs(msg tea.KeyMsg) tea.Cmd { cmds := make([]tea.Cmd, len(m.inputs)) for i := range m.inputs { m.inputs[i], cmds[i] = m.inputs[i].Update(msg) } return tea.Batch(cmds...) } func (m WizardModel) View() string { switch m.state { case stateForm: return m.viewForm() case stateSearching: return m.viewSearching() case stateResults: return m.viewResults() case stateGenerating: return m.viewGenerating() case stateError: return m.viewError() case stateDone: return "" } return "" } func (m WizardModel) viewForm() string { var b strings.Builder b.WriteString(wizardTitleStyle.Render("Skiptrace Person Search")) b.WriteString("\n\n") for i := range m.inputs { b.WriteString(m.inputs[i].View()) b.WriteString("\n") } b.WriteString("\n") button := blurredButton.Render("[ Search ]") if m.focusIndex == len(m.inputs) { button = focusedButton.Render("[ Search ]") } b.WriteString(button) if m.err != nil { b.WriteString("\n\n") b.WriteString(errorStyle.Render(m.err.Error())) } b.WriteString("\n\n") b.WriteString(helpStyle.Render("tab/shift+tab: navigate | enter: submit | esc: quit")) return b.String() } func (m WizardModel) viewSearching() string { return wizardTitleStyle.Render("Skiptrace Person Search") + "\n\n" + statusStyle.Render("Searching...") } func (m WizardModel) viewResults() string { var b strings.Builder b.WriteString(wizardTitleStyle.Render("Select a Person")) b.WriteString("\n") b.WriteString(statusStyle.Render(fmt.Sprintf("Found %d results", len(m.people)))) b.WriteString("\n\n") visibleHeight := m.resultsVisibleHeight() endIdx := m.offset + visibleHeight if endIdx > len(m.people) { endIdx = len(m.people) } if m.offset > 0 { b.WriteString(dimStyle.Render(fmt.Sprintf(" ↑ %d more above\n", m.offset))) } for i := m.offset; i < endIdx; i++ { p := m.people[i] isSelected := i == m.cursor cardStyle := normalCardStyle if isSelected { cardStyle = selectedCardStyle } var cardContent strings.Builder pointer := " " if isSelected { pointer = pointerStyle.Render("▶ ") } cardContent.WriteString(fmt.Sprintf("%s%s\n", pointer, nameStyle.Render(fmt.Sprintf("%d. %s", p.Index, p.Name)))) if p.Age != "" { cardContent.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Age:"), valueStyle.Render(p.Age))) } if p.Location != "" { cardContent.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Location:"), valueStyle.Render(p.Location))) } if p.PhoneHint != "" { cardContent.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Phone:"), valueStyle.Render(p.PhoneHint))) } content := strings.TrimSuffix(cardContent.String(), "\n") b.WriteString(cardStyle.Render(content)) b.WriteString("\n") } remaining := len(m.people) - endIdx if remaining > 0 { b.WriteString(dimStyle.Render(fmt.Sprintf(" ↓ %d more below\n", remaining))) } b.WriteString("\n") b.WriteString(helpStyle.Render("j/k or arrows: navigate | enter: select | esc: back | q: quit")) return b.String() } func (m WizardModel) viewGenerating() string { return wizardTitleStyle.Render("Generating Report") + "\n\n" + statusStyle.Render("Please wait, this may take a moment...") } func (m WizardModel) viewError() string { return wizardTitleStyle.Render("Error") + "\n\n" + errorStyle.Render(m.err.Error()) + "\n\n" + helpStyle.Render("press enter or q to exit") } func (m WizardModel) GetReportData() map[string]interface{} { return m.reportData } func RunSkiptraceWizard(searchFn SearchFunc, reportFn ReportFunc, jsonOutput bool) error { tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0) if err != nil { return fmt.Errorf("failed to open terminal: %w", err) } defer tty.Close() model := NewWizardModel(searchFn, reportFn, jsonOutput) p := tea.NewProgram(model, tea.WithInput(tty), tea.WithOutput(tty)) finalModel, err := p.Run() if err != nil { return fmt.Errorf("wizard error: %w", err) } m, ok := finalModel.(WizardModel) if !ok { return fmt.Errorf("unexpected model type") } if m.state == stateError { return m.err } if m.reportData != nil { if jsonOutput { output, err := FormatReportJSON(m.reportData) if err != nil { return err } fmt.Println(output) } else { fmt.Println(formatter.FormatSkiptraceReport(m.reportData)) } } return nil }