summaryrefslogtreecommitdiffstats
path: root/internal/tui
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tui')
-rw-r--r--internal/tui/person_selector.go426
-rw-r--r--internal/tui/wizard.go478
2 files changed, 904 insertions, 0 deletions
diff --git a/internal/tui/person_selector.go b/internal/tui/person_selector.go
new file mode 100644
index 0000000..2ae8caf
--- /dev/null
+++ b/internal/tui/person_selector.go
@@ -0,0 +1,426 @@
+package tui
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+var (
+ titleStyle = lipgloss.NewStyle().
+ Bold(true).
+ Foreground(lipgloss.Color("86"))
+
+ selectedStyle = lipgloss.NewStyle().
+ Bold(true).
+ Foreground(lipgloss.Color("229")).
+ Background(lipgloss.Color("57"))
+
+ normalStyle = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("252"))
+
+ dimStyle = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("243"))
+
+ helpStyle = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("241"))
+
+ errorStyle = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("196"))
+)
+
+type Person struct {
+ Index int
+ Name string
+ Age string
+ Location string
+ PhoneHint string
+ Raw map[string]interface{}
+}
+
+type PersonSelectorModel struct {
+ people []Person
+ cursor int
+ selected int
+ quitting bool
+ err error
+ windowHeight int
+ offset int
+}
+
+func NewPersonSelector(searchData map[string]interface{}) (*PersonSelectorModel, error) {
+ people, err := extractPeople(searchData)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(people) == 0 {
+ return nil, fmt.Errorf("no people found in search results")
+ }
+
+ return &PersonSelectorModel{
+ people: people,
+ cursor: 0,
+ selected: -1,
+ }, nil
+}
+
+func extractPeople(data map[string]interface{}) ([]Person, error) {
+ results, ok := data["results"].([]interface{})
+ if !ok {
+ if people, ok := data["people"].([]interface{}); ok {
+ results = people
+ } else if possiblePeople, ok := data["possible_people"].([]interface{}); ok {
+ results = possiblePeople
+ } else {
+ return nil, fmt.Errorf("no results, people, or possible_people found in response")
+ }
+ }
+
+ var people []Person
+ for i, r := range results {
+ personData, ok := r.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ p := Person{
+ Index: i + 1,
+ Raw: personData,
+ }
+
+ if names, ok := personData["names"].([]interface{}); ok && len(names) > 0 {
+ if nameData, ok := names[0].(map[string]interface{}); ok {
+ if display, ok := nameData["display"].(string); ok {
+ p.Name = display
+ }
+ }
+ }
+
+ if p.Name == "" {
+ if name, ok := personData["name"].(string); ok {
+ p.Name = name
+ }
+ }
+
+ if p.Name == "" {
+ p.Name = fmt.Sprintf("Person %d", i+1)
+ }
+
+ p.Age = extractAge(personData)
+ p.Location = extractLocation(personData)
+ p.PhoneHint = extractPhoneHint(personData)
+
+ people = append(people, p)
+ }
+
+ return people, nil
+}
+
+func extractAge(personData map[string]interface{}) string {
+ if age, ok := personData["age"].(float64); ok && age > 0 {
+ return fmt.Sprintf("%d", int(age))
+ }
+
+ if age, ok := personData["age"].(string); ok && age != "" {
+ return age
+ }
+
+ if dob, ok := personData["dob"].(map[string]interface{}); ok {
+ if age, ok := dob["age"].(float64); ok && age > 0 {
+ return fmt.Sprintf("%d", int(age))
+ }
+ if display, ok := dob["display"].(string); ok && display != "" {
+ return display
+ }
+ }
+
+ if dob, ok := personData["dob"].(string); ok && dob != "" {
+ return dob
+ }
+
+ return ""
+}
+
+func extractLocation(personData map[string]interface{}) string {
+ if location, ok := personData["location"].(string); ok && location != "" {
+ return location
+ }
+
+ if addresses, ok := personData["addresses"].([]interface{}); ok && len(addresses) > 0 {
+ if addr, ok := addresses[0].(map[string]interface{}); ok {
+ if display, ok := addr["display"].(string); ok && display != "" {
+ return display
+ }
+ var parts []string
+ if city, ok := addr["city"].(string); ok && city != "" {
+ parts = append(parts, city)
+ }
+ if state, ok := addr["state"].(string); ok && state != "" {
+ parts = append(parts, state)
+ }
+ if len(parts) > 0 {
+ return strings.Join(parts, ", ")
+ }
+ }
+ }
+
+ if address, ok := personData["address"].(map[string]interface{}); ok {
+ if display, ok := address["display"].(string); ok && display != "" {
+ return display
+ }
+ var parts []string
+ if city, ok := address["city"].(string); ok && city != "" {
+ parts = append(parts, city)
+ }
+ if state, ok := address["state"].(string); ok && state != "" {
+ parts = append(parts, state)
+ }
+ if len(parts) > 0 {
+ return strings.Join(parts, ", ")
+ }
+ }
+
+ return ""
+}
+
+func extractPhoneHint(personData map[string]interface{}) string {
+ if phones, ok := personData["phones"].(string); ok && phones != "" {
+ return phones
+ }
+
+ if phoneHint, ok := personData["phone_hint"].(string); ok && phoneHint != "" {
+ return phoneHint
+ }
+
+ if phones, ok := personData["phones"].([]interface{}); ok && len(phones) > 0 {
+ if phone, ok := phones[0].(map[string]interface{}); ok {
+ if display, ok := phone["display"].(string); ok && display != "" {
+ return maskPhone(display)
+ }
+ if number, ok := phone["number"].(string); ok && number != "" {
+ return maskPhone(number)
+ }
+ if hint, ok := phone["display_international"].(string); ok && hint != "" {
+ return maskPhone(hint)
+ }
+ }
+ if phoneStr, ok := phones[0].(string); ok && phoneStr != "" {
+ return maskPhone(phoneStr)
+ }
+ }
+
+ return ""
+}
+
+func maskPhone(phone string) string {
+ digits := ""
+ for _, c := range phone {
+ if c >= '0' && c <= '9' {
+ digits += string(c)
+ }
+ }
+
+ if len(digits) >= 10 {
+ last4 := digits[len(digits)-4:]
+ return "***-***-" + last4
+ }
+
+ if len(digits) >= 4 {
+ return "***-" + digits[len(digits)-4:]
+ }
+
+ return phone
+}
+
+func (m PersonSelectorModel) Init() tea.Cmd {
+ return nil
+}
+
+func (m PersonSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.windowHeight = msg.Height - 5
+ if m.windowHeight < 3 {
+ m.windowHeight = 3
+ }
+ case tea.KeyMsg:
+ switch msg.String() {
+ case "ctrl+c", "q":
+ m.quitting = true
+ return m, tea.Quit
+ 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.visibleHeight()
+ if m.cursor >= m.offset+visibleHeight {
+ m.offset = m.cursor - visibleHeight + 1
+ }
+ }
+ case "enter", " ":
+ m.selected = m.cursor
+ return m, tea.Quit
+ case "home":
+ m.cursor = 0
+ m.offset = 0
+ case "end":
+ m.cursor = len(m.people) - 1
+ visibleHeight := m.visibleHeight()
+ if len(m.people) > visibleHeight {
+ m.offset = len(m.people) - visibleHeight
+ }
+ case "pgup":
+ visibleHeight := m.visibleHeight()
+ m.cursor -= visibleHeight
+ if m.cursor < 0 {
+ m.cursor = 0
+ }
+ m.offset -= visibleHeight
+ if m.offset < 0 {
+ m.offset = 0
+ }
+ case "pgdown":
+ visibleHeight := m.visibleHeight()
+ m.cursor += visibleHeight
+ if m.cursor >= len(m.people) {
+ m.cursor = len(m.people) - 1
+ }
+ m.offset += visibleHeight
+ maxOffset := len(m.people) - visibleHeight
+ if maxOffset < 0 {
+ maxOffset = 0
+ }
+ if m.offset > maxOffset {
+ m.offset = maxOffset
+ }
+ }
+ }
+ return m, nil
+}
+
+func (m PersonSelectorModel) visibleHeight() int {
+ if m.windowHeight > 0 {
+ return m.windowHeight
+ }
+ return 20
+}
+
+func (m PersonSelectorModel) View() string {
+ if m.quitting && m.selected == -1 {
+ return ""
+ }
+
+ var b strings.Builder
+
+ b.WriteString(titleStyle.Render("Select a person to generate report:"))
+ b.WriteString("\n\n")
+
+ visibleHeight := m.visibleHeight()
+ 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]
+ cursor := " "
+ style := normalStyle
+
+ if i == m.cursor {
+ cursor = "> "
+ style = selectedStyle
+ }
+
+ line := fmt.Sprintf("%s%d. %s", cursor, p.Index, p.Name)
+
+ details := []string{}
+ if p.Age != "" {
+ details = append(details, fmt.Sprintf("Age: %s", p.Age))
+ }
+ if p.Location != "" {
+ details = append(details, p.Location)
+ }
+ if p.PhoneHint != "" {
+ details = append(details, fmt.Sprintf("Phone: %s", p.PhoneHint))
+ }
+
+ if len(details) > 0 {
+ line += dimStyle.Render(fmt.Sprintf(" (%s)", strings.Join(details, ", ")))
+ }
+
+ b.WriteString(style.Render(line))
+ 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("↑/k up • ↓/j down • pgup/pgdn • home/end • enter select • q quit"))
+
+ return b.String()
+}
+
+func (m PersonSelectorModel) Selected() int {
+ return m.selected
+}
+
+func (m PersonSelectorModel) WasQuit() bool {
+ return m.quitting && m.selected == -1
+}
+
+func RunPersonSelector(searchData map[string]interface{}) (int, error) {
+ model, err := NewPersonSelector(searchData)
+ if err != nil {
+ return -1, err
+ }
+
+ tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
+ if err != nil {
+ return -1, fmt.Errorf("failed to open terminal: %w", err)
+ }
+ defer tty.Close()
+
+ p := tea.NewProgram(model, tea.WithInput(tty), tea.WithOutput(tty))
+
+ finalModel, err := p.Run()
+ if err != nil {
+ return -1, fmt.Errorf("failed to run selector: %w", err)
+ }
+
+ m, ok := finalModel.(PersonSelectorModel)
+ if !ok {
+ return -1, fmt.Errorf("unexpected model type")
+ }
+
+ if m.WasQuit() {
+ return -1, fmt.Errorf("selection cancelled")
+ }
+
+ return m.Selected() + 1, nil
+}
+
+func FormatReportJSON(reportData map[string]interface{}) (string, error) {
+ output, err := json.MarshalIndent(reportData, "", " ")
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal report: %w", err)
+ }
+ return string(output), nil
+}
diff --git a/internal/tui/wizard.go b/internal/tui/wizard.go
new file mode 100644
index 0000000..34ba70e
--- /dev/null
+++ b/internal/tui/wizard.go
@@ -0,0 +1,478 @@
+package tui
+
+import (
+ "fmt"
+ "os"
+ "strings"
+
+ "git.db.org.ai/dborg/internal/formatter"
+ "github.com/charmbracelet/bubbles/table"
+ "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
+ table table.Model
+ people []Person
+ 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)
+
+ tableStyle = lipgloss.NewStyle().
+ BorderStyle(lipgloss.NormalBorder()).
+ BorderForeground(lipgloss.Color("240"))
+
+ 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.table = m.buildTable()
+ 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.table.Cursor()
+ m.state = stateGenerating
+ return m, m.doReport()
+ }
+
+ var cmd tea.Cmd
+ m.table, cmd = m.table.Update(msg)
+ return m, cmd
+}
+
+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) buildTable() table.Model {
+ columns := []table.Column{
+ {Title: "#", Width: 4},
+ {Title: "Name", Width: 25},
+ {Title: "Age", Width: 6},
+ {Title: "Location", Width: 25},
+ {Title: "Phone Hint", Width: 15},
+ }
+
+ rows := make([]table.Row, len(m.people))
+ for i, p := range m.people {
+ rows[i] = table.Row{
+ fmt.Sprintf("%d", p.Index),
+ p.Name,
+ p.Age,
+ p.Location,
+ p.PhoneHint,
+ }
+ }
+
+ t := table.New(
+ table.WithColumns(columns),
+ table.WithRows(rows),
+ table.WithFocused(true),
+ table.WithHeight(min(len(rows)+1, 15)),
+ )
+
+ s := table.DefaultStyles()
+ s.Header = s.Header.
+ BorderStyle(lipgloss.NormalBorder()).
+ BorderForeground(lipgloss.Color("240")).
+ BorderBottom(true).
+ Bold(false)
+ s.Selected = s.Selected.
+ Foreground(lipgloss.Color("229")).
+ Background(lipgloss.Color("57")).
+ Bold(false)
+ t.SetStyles(s)
+
+ return t
+}
+
+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")
+
+ b.WriteString(tableStyle.Render(m.table.View()))
+
+ b.WriteString("\n\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
+}