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")) selectedCardStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("86")). Padding(0, 1) normalCardStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("240")). Padding(0, 1) nameStyle = lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("229")) labelStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("86")) valueStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("252")) dimStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("243")) helpStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("241")) pointerStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("86")). Bold(true) 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 / 6 } return 5 } 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] 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("↑/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 }