package tui import ( "fmt" "os" "path/filepath" "strings" "github.com/cobr-ai/apex/internal/intake" "github.com/cobr-ai/apex/internal/store" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) const ( TabIntake = "Intake" TabScan = "Scan" TabEvaluate = "Evaluate" TabTracker = "Tracker" TabPipeline = "Pipeline" TabReports = "Reports" TabSettings = "Settings" ) type tabbedModel interface { tea.Model Resize(width, height int) } type App struct { activeTab string cursorTab int tabs []string models map[string]tea.Model db *store.DB dims layoutDims showHelp bool errBanner string } func NewApp() *App { app := &App{ activeTab: TabIntake, tabs: []string{TabIntake, TabScan, TabEvaluate, TabTracker, TabPipeline, TabReports, TabSettings}, models: make(map[string]tea.Model), } homeDir, err := os.UserHomeDir() if err != nil { homeDir = "/tmp" } dbPath := filepath.Join(homeDir, ".apex", "apex.db") os.MkdirAll(filepath.Dir(dbPath), 0755) db, err := store.NewDB(dbPath) if err != nil { app.errBanner = fmt.Sprintf("database init failed: %v", err) app.models[TabIntake] = NewPlaceholder("Intake — database unavailable") app.models[TabScan] = NewPlaceholder("Scan — database unavailable") app.models[TabEvaluate] = NewPlaceholder("Evaluate — database unavailable") app.models[TabTracker] = NewPlaceholder("Tracker — database unavailable") app.models[TabPipeline] = NewPlaceholder("Pipeline — database unavailable") app.models[TabReports] = NewPlaceholder("Reports — database unavailable") app.models[TabSettings] = NewPlaceholder("Settings — database unavailable") return app } app.db = db manager := intake.NewManager(db.Conn()) app.models[TabIntake] = NewIntakeModel(manager) scanCfgPath := filepath.Join(findRepoRoot(), "internal", "scan", "adapters.yaml") app.models[TabScan] = NewScanModel(scanCfgPath, app.db.Conn()) cvPath := filepath.Join(homeDir, ".apex", "cv.md") app.models[TabEvaluate] = NewEvalModel(app.db.Conn(), cvPath) app.models[TabTracker] = NewTrackerModel(app.db.Conn()) app.models[TabPipeline] = NewPipelineModel(app.db.Conn()) app.models[TabReports] = NewReportsModel(app.db.Conn()) app.models[TabSettings] = NewSettingsModel(app.db.Conn(), scanCfgPath) return app } func (a *App) Run() error { p := tea.NewProgram(a, tea.WithAltScreen()) _, err := p.Run() return err } func (a *App) Init() tea.Cmd { cmds := []tea.Cmd{} for _, m := range a.models { if m != nil { if c := m.Init(); c != nil { cmds = append(cmds, c) } } } return tea.Batch(cmds...) } func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: a.dims = computeLayout(msg.Width, msg.Height) for _, name := range a.tabs { if tm, ok := a.models[name].(tabbedModel); ok { tm.Resize(a.dims.contentW, a.dims.contentH) } } case tea.KeyMsg: key := msg.String() if a.showHelp { switch key { case "?", "esc", "q": a.showHelp = false return a, nil } return a, nil } switch key { case "ctrl+c": return a, tea.Quit case "?": a.showHelp = true return a, nil case "tab", "down": a.cursorTab = (a.cursorTab + 1) % len(a.tabs) a.activeTab = a.tabs[a.cursorTab] return a, nil case "shift+tab", "up": a.cursorTab = (a.cursorTab - 1 + len(a.tabs)) % len(a.tabs) a.activeTab = a.tabs[a.cursorTab] return a, nil } } model, cmd := a.models[a.activeTab].Update(msg) a.models[a.activeTab] = model return a, cmd } func (a *App) View() string { if a.dims.termW == 0 { return "initializing…" } title := StyleTitle.Render("apex") + " " + StyleSubtitle.Render("// job-search platform") sidebar := a.renderSidebar() content := a.renderContent() body := lipgloss.JoinHorizontal(lipgloss.Top, sidebar, content) footer := a.renderFooter() view := lipgloss.JoinVertical(lipgloss.Left, title, body, footer) if a.showHelp { overlay := a.renderHelp() return placeOverlay(view, overlay, a.dims.termW, a.dims.termH) } return view } func (a *App) renderSidebar() string { var items []string for _, t := range a.tabs { if t == a.activeTab { items = append(items, StyleSidebarActive.Render("▌ "+t)) } else { items = append(items, StyleSidebarItem.Render(" "+t)) } } inner := lipgloss.JoinVertical(lipgloss.Left, items...) return StyleSidebarBox. Width(sidebarWidth). Height(a.dims.sidebarH). Render(inner) } func (a *App) renderContent() string { m := a.models[a.activeTab] body := "" if m != nil { body = m.View() } return StyleContentBox. Width(a.dims.contentW). Height(a.dims.contentH). Render(body) } func (a *App) renderFooter() string { parts := []string{} add := func(k, d string) { parts = append(parts, StyleKey.Render(k)+" "+StyleKeyDesc.Render(d)) } add("↑/↓", "tab") add("j/k", "row") add("?", "help") add("q", "quit") if bindings, ok := a.activeTabBindings(); ok { parts = append(parts, StyleKeyDesc.Render("│")) for _, kb := range bindings { add(kb.Key, kb.Desc) } } line := strings.Join(parts, " ") if a.errBanner != "" { line = StyleStatusErr.Render(a.errBanner) + " " + line } return StyleFooter.Render(line) } func placeOverlay(bg, fg string, w, h int) string { bgLines := strings.Split(bg, "\n") fgLines := strings.Split(fg, "\n") fgH := len(fgLines) fgW := 0 for _, l := range fgLines { if ll := lipgloss.Width(l); ll > fgW { fgW = ll } } top := (h - fgH) / 2 left := (w - fgW) / 2 if top < 0 { top = 0 } if left < 0 { left = 0 } out := make([]string, len(bgLines)) copy(out, bgLines) for i, fl := range fgLines { y := top + i if y < 0 || y >= len(out) { continue } padded := padAnsiLeft(out[y], left) + fl out[y] = padded } return strings.Join(out, "\n") } func padAnsiLeft(s string, col int) string { w := lipgloss.Width(s) if w >= col { return truncDisplay(s, col) } return s + strings.Repeat(" ", col-w) } func truncDisplay(s string, n int) string { if lipgloss.Width(s) <= n { return s } runes := []rune(s) buf := strings.Builder{} width := 0 for _, r := range runes { cell := 1 if r > 0x2e80 { cell = 2 } if width+cell > n { break } buf.WriteRune(r) width += cell } return buf.String() }