initial public release

This commit is contained in:
2026-07-06 11:05:50 -04:00
commit ca518c5f8a
94 changed files with 15699 additions and 0 deletions
+271
View File
@@ -0,0 +1,271 @@
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()
}
+364
View File
@@ -0,0 +1,364 @@
package tui
import (
"context"
"database/sql"
"fmt"
"os"
"os/exec"
"strconv"
"time"
"github.com/cobr-ai/apex/internal/cv"
"github.com/cobr-ai/apex/internal/eval"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type EvalModel struct {
db *sql.DB
client *eval.Client
writer *eval.ReportWriter
cvPath string
cvExporter *cv.HTMLExporter
table table.Model
rows []evalRow
running bool
cvGenning bool
cancel context.CancelFunc
eventsCh <-chan eval.Event
status string
width int
height int
}
type evalRow struct {
URL string
Company string
Title string
Description string
Source string
Status string
Score float64
Legitimacy eval.LegitimacyTier
Err string
Seq int
ReportPath string
}
func NewEvalModel(db *sql.DB, cvPath string) *EvalModel {
m := &EvalModel{
db: db,
cvPath: cvPath,
client: eval.NewClient(),
writer: &eval.ReportWriter{DB: db},
cvExporter: &cv.HTMLExporter{
DB: db,
CVPath: cvPath,
},
status: "[r] refresh [e] evaluate [c] cv [enter] open report [esc] cancel",
}
m.loadPending()
m.table = newEvalTable(m.rows, 0, 0)
return m
}
func (m *EvalModel) loadPending() {
m.rows = nil
if m.db == nil {
m.status = "no database"
return
}
rows, err := m.db.Query(`
SELECT j.url, j.company, j.title, COALESCE(j.description, ''), COALESCE(j.source, '')
FROM jobs j
LEFT JOIN applications a ON a.job_id = j.id
WHERE a.id IS NULL AND j.archived = 0
ORDER BY j.discovered_at DESC
LIMIT 50
`)
if err != nil {
m.status = fmt.Sprintf("query failed: %v", err)
return
}
defer rows.Close()
for rows.Next() {
var r evalRow
if err := rows.Scan(&r.URL, &r.Company, &r.Title, &r.Description, &r.Source); err != nil {
continue
}
r.Status = "pending"
m.rows = append(m.rows, r)
}
}
func evalColumns() []table.Column {
return []table.Column{
{Title: "Company", Width: 22},
{Title: "Title", Width: 30},
{Title: "Status", Width: 8},
{Title: "Score", Width: 5},
{Title: "Legit", Width: 10},
{Title: "Notes", Width: 24},
}
}
func newEvalTable(rows []evalRow, w, h int) table.Model {
cols := flexColumns(w, evalColumns(), []int{0, 1, 5})
trows := make([]table.Row, 0, len(rows))
for _, r := range rows {
trows = append(trows, evalRowToTable(r))
}
t := table.New(
table.WithColumns(cols),
table.WithRows(trows),
table.WithFocused(true),
table.WithHeight(tableHeight(h)),
)
s := table.DefaultStyles()
s.Header = s.Header.BorderStyle(lipgloss.NormalBorder()).
BorderForeground(ColorBorder).BorderBottom(true).Bold(true).Foreground(ColorAccentAlt)
s.Selected = s.Selected.Bold(true).Foreground(ColorText).Background(ColorHighlight)
t.SetStyles(s)
return t
}
func evalRowToTable(r evalRow) table.Row {
score := "-"
if r.Score > 0 {
score = fmt.Sprintf("%.1f", r.Score)
}
legit := "-"
if r.Legitimacy != "" {
legit = string(r.Legitimacy)
}
notes := r.Err
if notes == "" && r.Seq > 0 {
notes = fmt.Sprintf("report #%03d", r.Seq)
}
return table.Row{
r.Company,
r.Title,
r.Status, score, legit, notes,
}
}
func (m *EvalModel) Init() tea.Cmd { return nil }
func (m *EvalModel) Resize(w, h int) {
m.width = w
m.height = h
m.table.SetHeight(tableHeight(h))
m.table.SetWidth(w - 2)
m.table.SetColumns(flexColumns(w-2, evalColumns(), []int{0, 1, 5}))
}
type evalEventMsg eval.Event
type evalDoneMsg struct{}
type cvGenDoneMsg struct {
path string
err error
}
func (m *EvalModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "r":
if !m.running {
m.loadPending()
m.refreshTable()
m.status = fmt.Sprintf("%d pending", len(m.rows))
}
case "e":
if !m.running && len(m.rows) > 0 {
return m, m.startBatch()
}
case "c":
if !m.running && !m.cvGenning && len(m.rows) > 0 {
cur := m.table.Cursor()
if cur < len(m.rows) {
return m, m.startCVGen(m.rows[cur].URL)
}
}
case "esc":
if m.running && m.cancel != nil {
m.cancel()
}
case "enter":
if cur := m.table.Cursor(); cur < len(m.rows) && m.rows[cur].Seq > 0 {
path := m.rows[cur].ReportPath
if path == "" {
path = m.lookupReportPath(m.rows[cur].URL)
}
if path != "" {
m.openInPager(path)
}
}
}
case evalEventMsg:
m.applyEvent(eval.Event(msg))
m.refreshTable()
return m, m.pump()
case evalDoneMsg:
m.running = false
m.status = m.summary()
m.refreshTable()
case cvGenDoneMsg:
m.cvGenning = false
if msg.err != nil {
m.status = fmt.Sprintf("cv error: %v", msg.err)
} else {
m.status = fmt.Sprintf("cv: %s", msg.path)
}
}
var cmd tea.Cmd
m.table, cmd = m.table.Update(msg)
return m, cmd
}
func (m *EvalModel) lookupReportPath(url string) string {
if m.db == nil {
return ""
}
var path string
err := m.db.QueryRow(`
SELECT r.file_path FROM reports r
JOIN applications a ON a.id = r.application_id
JOIN jobs j ON j.id = a.job_id
WHERE j.url = ? ORDER BY r.id DESC LIMIT 1
`, url).Scan(&path)
if err != nil {
return ""
}
return path
}
func (m *EvalModel) openInPager(path string) {
pager := os.Getenv("PAGER")
if pager == "" {
pager = "less"
}
cmd := exec.Command(pager, path)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Run()
}
func (m *EvalModel) startBatch() tea.Cmd {
if err := m.client.Available(); err != nil {
m.status = fmt.Sprintf("claude unavailable: %v", err)
return nil
}
jobs := make([]eval.JobContext, 0, len(m.rows))
for _, r := range m.rows {
jobs = append(jobs, eval.JobContext{
URL: r.URL, Company: r.Company, Title: r.Title, Source: r.Source, JDText: r.Description,
})
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
m.cancel = cancel
minScore := 0.0
if raw := os.Getenv("APEX_MIN_SCORE"); raw != "" {
if v, err := strconv.ParseFloat(raw, 64); err == nil {
minScore = v
}
}
b := &eval.Batch{
Client: m.client, Writer: m.writer, DB: m.db, CVPath: m.cvPath,
Concurrency: eval.DefaultConcurrency, MinScore: minScore,
}
m.eventsCh = b.Run(ctx, jobs)
m.running = true
m.status = "evaluating…"
return m.pump()
}
func (m *EvalModel) pump() tea.Cmd {
return func() tea.Msg {
ev, ok := <-m.eventsCh
if !ok {
return evalDoneMsg{}
}
return evalEventMsg(ev)
}
}
func (m *EvalModel) applyEvent(ev eval.Event) {
for i := range m.rows {
if m.rows[i].URL != ev.Job.URL {
continue
}
switch ev.Kind {
case eval.EventStart:
m.rows[i].Status = "running"
case eval.EventDone:
m.rows[i].Status = "done"
m.rows[i].Score = ev.Eval.Score
m.rows[i].Legitimacy = ev.Eval.Legitimacy
m.rows[i].Seq = ev.Saved.Seq
case eval.EventSkipped:
m.rows[i].Status = "skipped"
m.rows[i].Score = ev.Eval.Score
m.rows[i].Legitimacy = ev.Eval.Legitimacy
m.rows[i].Seq = ev.Saved.Seq
m.rows[i].Err = "below min-score"
case eval.EventError:
m.rows[i].Status = "error"
if ev.Err != nil {
m.rows[i].Err = ev.Err.Error()
}
}
return
}
}
func (m *EvalModel) refreshTable() {
trows := make([]table.Row, 0, len(m.rows))
for _, r := range m.rows {
trows = append(trows, evalRowToTable(r))
}
m.table.SetRows(trows)
}
func (m *EvalModel) summary() string {
var done, errs int
for _, r := range m.rows {
switch r.Status {
case "done":
done++
case "error":
errs++
}
}
return fmt.Sprintf("done — %d evaluated · %d errors", done, errs)
}
func (m *EvalModel) View() string {
header := StyleTitle.Render("Evaluate — A-G pipeline")
sub := StyleSubtitle.Render(fmt.Sprintf("%d pending · [r] refresh · [e] evaluate · [c] cv · [enter] open report · [esc] cancel", len(m.rows)))
status := StyleStatusMuted.Render(m.status)
return lipgloss.JoinVertical(lipgloss.Left, header, sub, "", m.table.View(), "", status)
}
// startCVGen initiates CV generation for the given job URL in a goroutine.
func (m *EvalModel) startCVGen(jobURL string) tea.Cmd {
if m.cvGenning || m.cvExporter == nil || m.cvExporter.DB == nil {
return nil
}
m.cvGenning = true
m.status = "generating cv…"
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
result, err := m.cvExporter.Generate(ctx, jobURL)
if err != nil {
return cvGenDoneMsg{err: err}
}
return cvGenDoneMsg{path: result.HTMLPath}
}
}
+99
View File
@@ -0,0 +1,99 @@
package tui
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
type helpBinding struct {
Key string
Desc string
}
func (a *App) activeTabBindings() ([]helpBinding, bool) {
switch a.activeTab {
case TabScan:
return []helpBinding{
{"s", "start scan"},
{"r", "retry errored rows"},
{"enter", "show row detail"},
{"esc", "cancel running scan / close modal"},
}, true
case TabEvaluate:
return []helpBinding{
{"r", "refresh pending"},
{"e", "evaluate pending batch"},
{"enter", "open report"},
{"esc", "cancel running eval / close modal"},
}, true
case TabTracker:
return []helpBinding{
{"r", "refresh list"},
{"a", "advance status (state machine)"},
{"n", "add note to selected app"},
{"f", "show pending follow-ups"},
}, true
case TabReports:
return []helpBinding{
{"enter", "open report file"},
{"/", "filter by company"},
{"r", "refresh list"},
}, true
case TabSettings:
return []helpBinding{
{"↑/↓", "move between fields"},
{"enter", "edit field"},
{"esc", "cancel edit"},
}, true
case TabIntake:
return []helpBinding{
{"tab", "next field"},
{"enter", "advance phase"},
}, true
}
return nil, false
}
func (a *App) renderHelp() string {
var b strings.Builder
b.WriteString(StyleTitle.Render("Help — keybindings"))
b.WriteString("\n\n")
b.WriteString(StyleSubtitle.Render("Global"))
b.WriteString("\n")
global := []helpBinding{
{"↑/↓", "switch tab (sidebar)"},
{"j/k", "move cursor in table"},
{"enter", "select / drill in"},
{"tab/shift+tab", "switch tab"},
{"?", "toggle help"},
{"q / ctrl+c", "quit"},
}
for _, kb := range global {
b.WriteString(" ")
b.WriteString(StyleKey.Render(padRight(kb.Key, 14)))
b.WriteString(StyleKeyDesc.Render(kb.Desc))
b.WriteString("\n")
}
if tab, ok := a.activeTabBindings(); ok {
b.WriteString("\n")
b.WriteString(StyleSubtitle.Render(a.activeTab))
b.WriteString("\n")
for _, kb := range tab {
b.WriteString(" ")
b.WriteString(StyleKey.Render(padRight(kb.Key, 14)))
b.WriteString(StyleKeyDesc.Render(kb.Desc))
b.WriteString("\n")
}
}
b.WriteString("\n")
b.WriteString(StyleKeyDesc.Render("press ? or esc to close"))
return lipgloss.NewStyle().Width(52).Render(StyleModal.Render(b.String()))
}
func padRight(s string, n int) string {
if len(s) >= n {
return s
}
return s + strings.Repeat(" ", n-len(s))
}
+322
View File
@@ -0,0 +1,322 @@
package tui
import (
"fmt"
"time"
"github.com/cobr-ai/apex/internal/intake"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// IntakeModel represents the Intake tab state machine
type IntakeModel struct {
session *intake.IntakeSession
manager *intake.Manager
currentView IntakeView
formInputs []textinput.Model
focusedInput int
errorMsg string
}
// IntakeView represents which view is being shown
type IntakeView int
const (
ViewWelcome IntakeView = iota
ViewIdentity
ViewEducation
ViewRoles
ViewCertifications
ViewSkills
ViewProjects
ViewPreferences
ViewVoice
ViewReview
ViewComplete
)
// NewIntakeModel creates a new intake model
func NewIntakeModel(manager *intake.Manager) *IntakeModel {
return &IntakeModel{
manager: manager,
currentView: ViewWelcome,
formInputs: make([]textinput.Model, 0),
}
}
// Init initializes the intake model
func (m *IntakeModel) Init() tea.Cmd {
session, _ := m.manager.GetCurrentSession()
if session != nil {
m.session = session
m.currentView = ViewWelcome + IntakeView(session.CurrentPhase)
if m.currentView >= ViewComplete {
m.currentView = ViewComplete
}
}
return nil
}
// Update handles user input
func (m *IntakeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "tab":
if len(m.formInputs) > 0 {
m.focusedInput = (m.focusedInput + 1) % len(m.formInputs)
m.updateFocus()
}
case "shift+tab":
if len(m.formInputs) > 0 {
m.focusedInput = (m.focusedInput - 1 + len(m.formInputs)) % len(m.formInputs)
m.updateFocus()
}
case "enter":
return m.handleSubmit()
case "n":
if m.currentView == ViewWelcome {
m.currentView = ViewIdentity
m.setupFormForPhase(intake.PhaseIdentity)
}
}
}
if len(m.formInputs) > 0 && m.focusedInput < len(m.formInputs) {
var cmd tea.Cmd
m.formInputs[m.focusedInput], cmd = m.formInputs[m.focusedInput].Update(msg)
return m, cmd
}
return m, nil
}
// View renders the intake form
func (m *IntakeModel) View() string {
switch m.currentView {
case ViewWelcome:
return m.renderWelcome()
case ViewIdentity:
return m.renderIdentityForm()
case ViewEducation, ViewRoles, ViewCertifications, ViewProjects:
return m.renderMultiItemForm()
case ViewSkills, ViewPreferences, ViewVoice:
return m.renderForm()
case ViewReview:
return m.renderReview()
case ViewComplete:
return m.renderComplete()
default:
return "Unknown view"
}
}
func (m *IntakeModel) renderWelcome() string {
title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("4")).Render("Welcome to Apex Intake")
options := "[n] Start New | [i] Import DOCX | [r] Resume | [q] Quit"
return lipgloss.JoinVertical(lipgloss.Top, title, options)
}
func (m *IntakeModel) renderIdentityForm() string {
title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("4")).Render("Phase 1: Identity")
inputs := lipgloss.JoinVertical(lipgloss.Top, m.renderFormInputs()...)
errorLine := ""
if m.errorMsg != "" {
errorLine = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Render("Error: " + m.errorMsg)
}
return lipgloss.JoinVertical(lipgloss.Top, title, "", inputs, errorLine)
}
func (m *IntakeModel) renderForm() string {
title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("4")).Render("Intake Form")
inputs := lipgloss.JoinVertical(lipgloss.Top, m.renderFormInputs()...)
errorLine := ""
if m.errorMsg != "" {
errorLine = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Render("Error: " + m.errorMsg)
}
return lipgloss.JoinVertical(lipgloss.Top, title, "", inputs, errorLine)
}
func (m *IntakeModel) renderMultiItemForm() string {
title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("4")).Render("Multi-Item Phase")
inputs := lipgloss.JoinVertical(lipgloss.Top, m.renderFormInputs()...)
errorLine := ""
if m.errorMsg != "" {
errorLine = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Render("Error: " + m.errorMsg)
}
return lipgloss.JoinVertical(lipgloss.Top, title, "", inputs, errorLine)
}
func (m *IntakeModel) renderFormInputs() []string {
var lines []string
for i, input := range m.formInputs {
style := lipgloss.NewStyle().Width(60)
if i == m.focusedInput {
style = style.Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("4"))
}
lines = append(lines, style.Render(input.View()))
}
return lines
}
func (m *IntakeModel) renderReview() string {
title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("4")).Render("Review")
return title
}
func (m *IntakeModel) renderComplete() string {
title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("2")).Render("Intake Complete!")
return title
}
func (m *IntakeModel) updateFocus() {
for i := range m.formInputs {
if i == m.focusedInput {
m.formInputs[i].Focus()
} else {
m.formInputs[i].Blur()
}
}
}
func (m *IntakeModel) handleSubmit() (tea.Model, tea.Cmd) {
if m.session == nil {
m.errorMsg = "No session loaded"
return m, nil
}
// Collect form input into answer structure
answer := intake.FormAnswer{
Phase: m.session.CurrentPhase,
Fields: make(map[string]string),
NestedItems: []map[string]string{},
SubmittedAt: time.Now(),
}
// Map form inputs to fields based on current phase
switch m.session.CurrentPhase {
case intake.PhaseIdentity:
if len(m.formInputs) >= 4 {
answer.Fields["name"] = m.formInputs[0].Value()
answer.Fields["email"] = m.formInputs[1].Value()
answer.Fields["location"] = m.formInputs[2].Value()
answer.Fields["timezone"] = m.formInputs[3].Value()
}
case intake.PhaseEducation:
// Multi-item: accumulate from form inputs
for _, input := range m.formInputs {
if input.Value() != "" {
answer.NestedItems = append(answer.NestedItems, map[string]string{
"school": input.Value(),
})
}
}
case intake.PhaseRoles:
for _, input := range m.formInputs {
if input.Value() != "" {
answer.NestedItems = append(answer.NestedItems, map[string]string{
"title": input.Value(),
})
}
}
case intake.PhaseCertifications, intake.PhaseProjects:
for _, input := range m.formInputs {
if input.Value() != "" {
answer.NestedItems = append(answer.NestedItems, map[string]string{
"name": input.Value(),
})
}
}
default:
// Single-item phases
for i, input := range m.formInputs {
answer.Fields[fmt.Sprintf("field_%d", i)] = input.Value()
}
}
// Validate required fields
if !m.validateAnswer(answer) {
m.errorMsg = "Please fill all required fields"
return m, nil
}
// Save answer to database
if err := m.manager.SubmitPhaseAnswer(m.session, answer); err != nil {
m.errorMsg = fmt.Sprintf("Error saving: %v", err)
return m, nil
}
// Advance to next phase
nextPhase := m.manager.AdvanceToNextPhase(m.session)
// Clear error and setup next view
m.errorMsg = ""
m.currentView = ViewIdentity + IntakeView(nextPhase)
if nextPhase == intake.PhaseComplete {
m.manager.CompleteIntake(m.session)
m.currentView = ViewComplete
} else {
m.setupFormForPhase(nextPhase)
}
return m, nil
}
func (m *IntakeModel) validateAnswer(answer intake.FormAnswer) bool {
switch answer.Phase {
case intake.PhaseIdentity:
required := []string{"name", "email", "location", "timezone"}
for _, field := range required {
if answer.Fields[field] == "" {
return false
}
}
return true
case intake.PhaseEducation, intake.PhaseRoles, intake.PhaseCertifications, intake.PhaseProjects:
return len(answer.NestedItems) > 0
case intake.PhaseSkills, intake.PhasePreferences, intake.PhaseVoice:
return len(answer.Fields) > 0 || len(answer.NestedItems) > 0
}
return true
}
func (m *IntakeModel) setupFormForPhase(phase intake.Phase) {
m.formInputs = make([]textinput.Model, 0)
switch phase {
case intake.PhaseIdentity:
m.formInputs = make([]textinput.Model, 4)
labels := []string{"Name", "Email", "Location", "Timezone"}
for i := range m.formInputs {
m.formInputs[i] = textinput.New()
m.formInputs[i].Placeholder = labels[i]
if i == 0 {
m.formInputs[i].Focus()
}
}
case intake.PhaseEducation, intake.PhaseRoles, intake.PhaseCertifications, intake.PhaseProjects:
m.formInputs = make([]textinput.Model, 3) // Allow multiple items
for i := range m.formInputs {
m.formInputs[i] = textinput.New()
if i == 0 {
m.formInputs[i].Focus()
}
}
default:
m.formInputs = make([]textinput.Model, 2)
for i := range m.formInputs {
m.formInputs[i] = textinput.New()
if i == 0 {
m.formInputs[i].Focus()
}
}
}
m.focusedInput = 0
}
+84
View File
@@ -0,0 +1,84 @@
package tui
import "github.com/charmbracelet/bubbles/table"
const (
sidebarWidth = 18
footerHeight = 1
titleHeight = 2
boxChromeV = 2
boxChromeH = 4
)
// flexColumns grows the columns at flexIdx to fill totalW, distributing extra
// space proportionally to each flex column's existing width. Bubble-tea's
// table widget reserves padding around every cell, accounted for via colPadding.
func flexColumns(totalW int, cols []table.Column, flexIdx []int) []table.Column {
out := make([]table.Column, len(cols))
copy(out, cols)
if totalW <= 0 || len(flexIdx) == 0 {
return out
}
const colPadding = 2
used := 0
for _, c := range out {
used += c.Width + colPadding
}
extra := totalW - used
if extra <= 0 {
return out
}
totalFlex := 0
for _, i := range flexIdx {
if i < 0 || i >= len(out) {
return out
}
totalFlex += out[i].Width
}
if totalFlex == 0 {
return out
}
distributed := 0
for j, i := range flexIdx {
var add int
if j == len(flexIdx)-1 {
add = extra - distributed
} else {
add = extra * out[i].Width / totalFlex
}
out[i].Width += add
distributed += add
}
return out
}
type layoutDims struct {
termW, termH int
contentW int
contentH int
sidebarH int
}
func computeLayout(termW, termH int) layoutDims {
if termW < 60 {
termW = 60
}
if termH < 18 {
termH = 18
}
contentW := termW - sidebarWidth - boxChromeH - 1
if contentW < 40 {
contentW = 40
}
contentH := termH - titleHeight - footerHeight - boxChromeV
if contentH < 10 {
contentH = 10
}
return layoutDims{
termW: termW,
termH: termH,
contentW: contentW,
contentH: contentH,
sidebarH: contentH,
}
}
+33
View File
@@ -0,0 +1,33 @@
package tui
import (
"os"
"path/filepath"
)
// findRepoRoot walks up from the executable's location (then cwd) looking for
// internal/scan/adapters.yaml. Returns "." as fallback so the error surfaces
// through LoadConfig rather than here.
func findRepoRoot() string {
candidates := []string{}
if exe, err := os.Executable(); err == nil {
candidates = append(candidates, filepath.Dir(exe), filepath.Dir(filepath.Dir(exe)))
}
if wd, err := os.Getwd(); err == nil {
candidates = append(candidates, wd)
}
for _, start := range candidates {
d := start
for i := 0; i < 6; i++ {
if _, err := os.Stat(filepath.Join(d, "internal", "scan", "adapters.yaml")); err == nil {
return d
}
parent := filepath.Dir(d)
if parent == d {
break
}
d = parent
}
}
return "."
}
+354
View File
@@ -0,0 +1,354 @@
package tui
import (
"database/sql"
"fmt"
"os"
"os/exec"
"strings"
"time"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type PipelineModel struct {
db *sql.DB
table table.Model
rows []pipelineRow
filteredIdx []int
width int
height int
status string
mode pipelineMode
filterInput textinput.Model
}
type pipelineMode int
const (
pmList pipelineMode = iota
pmFilter
)
type pipelineRow struct {
ID int
JobID int
Company string
Title string
QueuedAt time.Time
StartedAt *time.Time
CompletedAt *time.Time
}
func (r *pipelineRow) Status() string {
if r.CompletedAt != nil {
return "done"
}
if r.StartedAt != nil {
return "running"
}
return "pending"
}
func NewPipelineModel(db *sql.DB) *PipelineModel {
ti := textinput.New()
ti.Placeholder = "filter by company or title…"
ti.CharLimit = 100
ti.Width = 60
m := &PipelineModel{
db: db,
filterInput: ti,
status: "[/] filter [d] dequeue [r] report [s] advance status [q/esc] clear",
}
m.loadRows()
m.applyFilter("")
m.table = newPipelineTable(m.visibleRows(), 0, 0)
return m
}
func (m *PipelineModel) loadRows() {
m.rows = nil
if m.db == nil {
m.status = "no database"
return
}
rows, err := m.db.Query(`
SELECT p.id, p.job_id, j.company, j.title, p.queued_at, p.started_at, p.completed_at
FROM pipeline p
JOIN jobs j ON j.id = p.job_id
ORDER BY p.queued_at DESC
`)
if err != nil {
m.status = fmt.Sprintf("query failed: %v", err)
return
}
defer rows.Close()
for rows.Next() {
var r pipelineRow
if err := rows.Scan(&r.ID, &r.JobID, &r.Company, &r.Title, &r.QueuedAt, &r.StartedAt, &r.CompletedAt); err != nil {
continue
}
m.rows = append(m.rows, r)
}
}
func pipelineColumns() []table.Column {
return []table.Column{
{Title: "ID", Width: 4},
{Title: "Company", Width: 20},
{Title: "Title", Width: 30},
{Title: "Queued", Width: 10},
{Title: "Status", Width: 9},
}
}
func newPipelineTable(rows []pipelineRow, w, h int) table.Model {
cols := flexColumns(w, pipelineColumns(), []int{1, 2})
trows := make([]table.Row, 0, len(rows))
for _, r := range rows {
trows = append(trows, pipelineRowToTable(r))
}
t := table.New(
table.WithColumns(cols),
table.WithRows(trows),
table.WithFocused(true),
table.WithHeight(tableHeight(h)),
)
s := table.DefaultStyles()
s.Header = s.Header.BorderStyle(lipgloss.NormalBorder()).
BorderForeground(ColorBorder).BorderBottom(true).Bold(true).Foreground(ColorAccentAlt)
s.Selected = s.Selected.Bold(true).Foreground(ColorText).Background(ColorHighlight)
t.SetStyles(s)
return t
}
func pipelineRowToTable(r pipelineRow) table.Row {
return table.Row{
fmt.Sprintf("%d", r.ID),
r.Company,
r.Title,
r.QueuedAt.Local().Format("2006-01-02"),
r.Status(),
}
}
func (m *PipelineModel) visibleRows() []pipelineRow {
if len(m.filteredIdx) == 0 {
return m.rows
}
result := make([]pipelineRow, 0, len(m.filteredIdx))
for _, i := range m.filteredIdx {
if i >= 0 && i < len(m.rows) {
result = append(result, m.rows[i])
}
}
return result
}
func (m *PipelineModel) applyFilter(query string) {
m.filteredIdx = nil
if query == "" {
return
}
lower := strings.ToLower(query)
for i, r := range m.rows {
if strings.Contains(strings.ToLower(r.Company), lower) ||
strings.Contains(strings.ToLower(r.Title), lower) {
m.filteredIdx = append(m.filteredIdx, i)
}
}
}
func (m *PipelineModel) selectedRowIndex() int {
cur := m.table.Cursor()
visible := m.visibleRows()
if cur >= len(visible) {
return -1
}
if len(m.filteredIdx) == 0 {
return cur
}
return m.filteredIdx[cur]
}
func (m *PipelineModel) Init() tea.Cmd { return nil }
func (m *PipelineModel) Resize(w, h int) {
m.width = w
m.height = h
m.table.SetHeight(tableHeight(h))
m.table.SetWidth(w - 2)
m.table.SetColumns(flexColumns(w-2, pipelineColumns(), []int{1, 2}))
}
func (m *PipelineModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.mode == pmFilter {
return m.updateFilter(msg)
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "/":
m.filterInput.SetValue("")
m.filterInput.Focus()
m.mode = pmFilter
m.status = "type to filter, enter to apply, esc to cancel"
case "d":
if idx := m.selectedRowIndex(); idx >= 0 {
return m, m.dequeueItem(m.rows[idx].ID)
}
case "r":
if idx := m.selectedRowIndex(); idx >= 0 {
m.openReport(m.rows[idx].JobID)
}
case "s":
if idx := m.selectedRowIndex(); idx >= 0 {
return m, m.advanceStatus(m.rows[idx].JobID)
}
case "q", "esc":
if len(m.filteredIdx) > 0 {
m.filteredIdx = nil
m.applyFilter("")
m.refreshTable()
m.status = "filter cleared"
}
}
}
var cmd tea.Cmd
m.table, cmd = m.table.Update(msg)
return m, cmd
}
func (m *PipelineModel) updateFilter(msg tea.Msg) (tea.Model, tea.Cmd) {
if k, ok := msg.(tea.KeyMsg); ok {
switch k.String() {
case "esc":
m.mode = pmList
m.status = "[/] filter [d] dequeue [r] report [s] advance status [q/esc] clear"
return m, nil
case "enter":
query := strings.TrimSpace(m.filterInput.Value())
m.applyFilter(query)
m.table.SetCursor(0)
m.refreshTable()
m.mode = pmList
pending := m.countStatus("pending")
m.status = fmt.Sprintf("%d items (filtered) · %d pending", len(m.visibleRows()), pending)
return m, nil
}
}
var cmd tea.Cmd
m.filterInput, cmd = m.filterInput.Update(msg)
return m, cmd
}
func (m *PipelineModel) dequeueItem(id int) tea.Cmd {
return func() tea.Msg {
_, err := m.db.Exec(`DELETE FROM pipeline WHERE id = ?`, id)
if err != nil {
m.status = fmt.Sprintf("dequeue failed: %v", err)
} else {
m.loadRows()
m.applyFilter(m.filterInput.Value())
m.refreshTable()
m.status = fmt.Sprintf("removed #%d from pipeline", id)
}
return nil
}
}
func (m *PipelineModel) openReport(jobID int) {
if m.db == nil {
return
}
var path string
err := m.db.QueryRow(`
SELECT r.file_path FROM reports r
JOIN applications a ON a.id = r.application_id
WHERE a.job_id = ? ORDER BY r.id DESC LIMIT 1
`, jobID).Scan(&path)
if err != nil {
m.status = fmt.Sprintf("no report found for job #%d", jobID)
return
}
pager := os.Getenv("PAGER")
if pager == "" {
pager = "less"
}
cmd := exec.Command(pager, path)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Run()
}
func (m *PipelineModel) advanceStatus(jobID int) tea.Cmd {
return func() tea.Msg {
var status string
err := m.db.QueryRow(`SELECT status FROM applications WHERE job_id = ? ORDER BY id DESC LIMIT 1`, jobID).Scan(&status)
if err == sql.ErrNoRows {
m.status = fmt.Sprintf("no application found for job #%d", jobID)
return nil
}
if err != nil {
m.status = fmt.Sprintf("query failed: %v", err)
return nil
}
if status != "Queued" {
m.status = fmt.Sprintf("job #%d is %s, not Queued; no advance possible", jobID, status)
return nil
}
_, err = m.db.Exec(`UPDATE applications SET status = ? WHERE job_id = ?`, "Evaluated", jobID)
if err != nil {
m.status = fmt.Sprintf("advance failed: %v", err)
} else {
m.status = fmt.Sprintf("job #%d advanced to Evaluated", jobID)
}
return nil
}
}
func (m *PipelineModel) countStatus(s string) int {
count := 0
for _, r := range m.visibleRows() {
if r.Status() == s {
count++
}
}
return count
}
func (m *PipelineModel) refreshTable() {
trows := make([]table.Row, 0, len(m.visibleRows()))
for _, r := range m.visibleRows() {
trows = append(trows, pipelineRowToTable(r))
}
m.table.SetRows(trows)
}
func (m *PipelineModel) View() string {
header := StyleTitle.Render("Pipeline — batch evaluation queue")
pending := m.countStatus("pending")
sub := StyleSubtitle.Render(fmt.Sprintf("%d items (%d pending) · [/] filter · [d] dequeue · [r] report · [s] advance · [q/esc] clear", len(m.visibleRows()), pending))
var body string
if m.mode == pmFilter {
body = m.renderFilterPrompt()
} else {
body = m.table.View()
}
status := StyleStatusMuted.Render(m.status)
return lipgloss.JoinVertical(lipgloss.Left, header, sub, "", body, "", status)
}
func (m *PipelineModel) renderFilterPrompt() string {
var b strings.Builder
b.WriteString("Filter by company or title:\n\n")
b.WriteString(m.filterInput.View())
b.WriteString("\n\n" + StyleKeyDesc.Render("[enter] apply · [esc] cancel"))
return StyleModal.Render(b.String())
}
+27
View File
@@ -0,0 +1,27 @@
package tui
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type Placeholder struct {
title string
}
func NewPlaceholder(title string) tea.Model {
return &Placeholder{title: title}
}
func (p *Placeholder) Init() tea.Cmd {
return nil
}
func (p *Placeholder) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return p, nil
}
func (p *Placeholder) View() string {
style := lipgloss.NewStyle().Padding(2, 4).Foreground(lipgloss.Color("8"))
return style.Render("[WIP] " + p.title)
}
+177
View File
@@ -0,0 +1,177 @@
package tui
import (
"database/sql"
"fmt"
"os"
"os/exec"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type ReportsModel struct {
db *sql.DB
table table.Model
rows []reportRow
status string
width int
height int
}
type reportRow struct {
ID int
Seq int
Company string
Title string
Score float64
Legitimacy string
FilePath string
CreatedAt string
}
func NewReportsModel(db *sql.DB) *ReportsModel {
m := &ReportsModel{db: db, status: "[r] refresh [enter] open report"}
m.load()
m.table = newReportsTable(m.rows, 0, 0)
return m
}
func (m *ReportsModel) load() {
m.rows = nil
if m.db == nil {
m.status = "no database"
return
}
rows, err := m.db.Query(`
SELECT r.id, COALESCE(r.seq,0), COALESCE(j.company,''), COALESCE(j.title,''),
COALESCE(r.score, 0), COALESCE(r.legitimacy, ''), COALESCE(r.file_path, ''),
COALESCE(r.created_at, '')
FROM reports r
LEFT JOIN applications a ON a.id = r.application_id
LEFT JOIN jobs j ON j.id = a.job_id
ORDER BY r.id DESC
LIMIT 200
`)
if err != nil {
m.status = fmt.Sprintf("query failed: %v", err)
return
}
defer rows.Close()
for rows.Next() {
var r reportRow
if err := rows.Scan(&r.ID, &r.Seq, &r.Company, &r.Title, &r.Score, &r.Legitimacy, &r.FilePath, &r.CreatedAt); err != nil {
continue
}
m.rows = append(m.rows, r)
}
}
func reportsColumns() []table.Column {
return []table.Column{
{Title: "#", Width: 5},
{Title: "Company", Width: 22},
{Title: "Title", Width: 30},
{Title: "Score", Width: 6},
{Title: "Legit", Width: 10},
{Title: "Created", Width: 19},
}
}
func newReportsTable(rows []reportRow, w, h int) table.Model {
cols := flexColumns(w, reportsColumns(), []int{1, 2})
trows := make([]table.Row, 0, len(rows))
for _, r := range rows {
trows = append(trows, reportRowToTable(r))
}
t := table.New(
table.WithColumns(cols),
table.WithRows(trows),
table.WithFocused(true),
table.WithHeight(tableHeight(h)),
)
s := table.DefaultStyles()
s.Header = s.Header.BorderStyle(lipgloss.NormalBorder()).
BorderForeground(ColorBorder).BorderBottom(true).Bold(true).Foreground(ColorAccentAlt)
s.Selected = s.Selected.Bold(true).Foreground(ColorText).Background(ColorHighlight)
t.SetStyles(s)
return t
}
func reportRowToTable(r reportRow) table.Row {
seq := "-"
if r.Seq > 0 {
seq = fmt.Sprintf("#%03d", r.Seq)
}
score := "-"
if r.Score > 0 {
score = fmt.Sprintf("%.1f", r.Score)
}
return table.Row{seq, r.Company, r.Title, score, r.Legitimacy, r.CreatedAt}
}
func (m *ReportsModel) Init() tea.Cmd { return nil }
func (m *ReportsModel) Resize(w, h int) {
m.width = w
m.height = h
m.table.SetHeight(tableHeight(h))
m.table.SetWidth(w - 2)
m.table.SetColumns(flexColumns(w-2, reportsColumns(), []int{1, 2}))
}
func (m *ReportsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "r":
m.load()
m.refreshTable()
m.status = fmt.Sprintf("%d reports", len(m.rows))
case "enter":
if cur := m.table.Cursor(); cur < len(m.rows) {
path := m.rows[cur].FilePath
if path == "" {
m.status = "no file_path on this report"
return m, nil
}
if _, err := os.Stat(path); err != nil {
m.status = fmt.Sprintf("missing: %s", path)
return m, nil
}
m.openInPager(path)
}
}
}
var cmd tea.Cmd
m.table, cmd = m.table.Update(msg)
return m, cmd
}
func (m *ReportsModel) refreshTable() {
trows := make([]table.Row, 0, len(m.rows))
for _, r := range m.rows {
trows = append(trows, reportRowToTable(r))
}
m.table.SetRows(trows)
}
func (m *ReportsModel) openInPager(path string) {
pager := os.Getenv("PAGER")
if pager == "" {
pager = "less"
}
cmd := exec.Command(pager, path)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Run()
}
func (m *ReportsModel) View() string {
header := StyleTitle.Render("Reports — evaluation history")
sub := StyleSubtitle.Render(fmt.Sprintf("%d reports · [r] refresh · [enter] open in $PAGER", len(m.rows)))
status := StyleStatusMuted.Render(m.status)
return lipgloss.JoinVertical(lipgloss.Left, header, sub, "", m.table.View(), "", status)
}
+325
View File
@@ -0,0 +1,325 @@
package tui
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"github.com/cobr-ai/apex/internal/scan"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type ScanModel struct {
configPath string
cfg *scan.Config
db *sql.DB
table table.Model
rows []scanRow
running bool
cancel context.CancelFunc
resultsCh <-chan scan.ScanResult
status string
width int
height int
showDetail bool
detailIdx int
}
type scanRow struct {
Name string
Status string
Found int
Filtered int
Duplicate int
New int
Err string
}
func NewScanModel(configPath string, db *sql.DB) *ScanModel {
m := &ScanModel{configPath: configPath, db: db, status: "press [s] to scan"}
cfg, err := scan.LoadConfig(configPath)
if err != nil {
m.status = fmt.Sprintf("config error: %v", err)
return m
}
m.cfg = cfg
for _, c := range cfg.TrackedCompanies {
st := "pending"
if !c.Enabled {
st = "disabled"
}
m.rows = append(m.rows, scanRow{Name: c.Name, Status: st})
}
m.table = newScanTable(m.rows, 0, 0)
return m
}
func scanColumns() []table.Column {
return []table.Column{
{Title: "Company", Width: 26},
{Title: "Status", Width: 11},
{Title: "Found", Width: 6},
{Title: "Filt", Width: 6},
{Title: "Dup", Width: 6},
{Title: "New", Width: 6},
{Title: "Notes", Width: 24},
}
}
func newScanTable(rows []scanRow, w, h int) table.Model {
cols := flexColumns(w, scanColumns(), []int{0, 6})
tableRows := make([]table.Row, 0, len(rows))
for _, r := range rows {
notes := r.Err
if notes == "" && r.Status == "unsupported" {
notes = "no tier-1 API"
}
tableRows = append(tableRows, table.Row{
r.Name, r.Status, intStr(r.Found), intStr(r.Filtered),
intStr(r.Duplicate), intStr(r.New), notes,
})
}
t := table.New(
table.WithColumns(cols),
table.WithRows(tableRows),
table.WithFocused(true),
table.WithHeight(tableHeight(h)),
)
s := table.DefaultStyles()
s.Header = s.Header.BorderStyle(lipgloss.NormalBorder()).
BorderForeground(ColorBorder).BorderBottom(true).Bold(true).Foreground(ColorAccentAlt)
s.Selected = s.Selected.Bold(true).Foreground(ColorText).Background(ColorHighlight)
t.SetStyles(s)
return t
}
func tableHeight(paneH int) int {
h := paneH - 6
if h < 5 {
h = 5
}
return h
}
func intStr(n int) string {
if n == 0 {
return "-"
}
return fmt.Sprintf("%d", n)
}
func (m *ScanModel) Init() tea.Cmd { return nil }
func (m *ScanModel) Resize(w, h int) {
m.width = w
m.height = h
m.table.SetHeight(tableHeight(h))
m.table.SetWidth(w - 2)
m.table.SetColumns(flexColumns(w-2, scanColumns(), []int{0, 6}))
}
type scanResultMsg scan.ScanResult
type scanDoneMsg struct{}
func (m *ScanModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.showDetail {
if k, ok := msg.(tea.KeyMsg); ok {
switch k.String() {
case "esc", "enter", "q":
m.showDetail = false
}
}
return m, nil
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "s":
if !m.running && m.cfg != nil {
return m, m.startScan()
}
case "esc":
if m.running && m.cancel != nil {
m.cancel()
}
case "r":
if !m.running {
return m, m.retryErrored()
}
case "enter":
m.detailIdx = m.table.Cursor()
m.showDetail = true
return m, nil
}
case scanResultMsg:
m.applyResult(scan.ScanResult(msg))
m.refreshTable()
return m, m.pump()
case scanDoneMsg:
m.running = false
m.status = m.summary()
m.refreshTable()
}
var cmd tea.Cmd
m.table, cmd = m.table.Update(msg)
return m, cmd
}
func (m *ScanModel) startScan() tea.Cmd {
if m.db == nil {
m.status = "scan unavailable: no database"
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
m.cancel = cancel
runner := scan.NewRunner(m.db, m.cfg)
m.resultsCh = runner.Run(ctx, m.cfg.TrackedCompanies)
m.running = true
m.status = "scanning…"
for i := range m.rows {
if m.rows[i].Status == "pending" || m.rows[i].Status == "error" {
m.rows[i].Status = "running"
m.rows[i].Err = ""
}
}
m.refreshTable()
return m.pump()
}
func (m *ScanModel) retryErrored() tea.Cmd {
if m.db == nil || m.cfg == nil {
return nil
}
var targets []scan.Company
for i, r := range m.rows {
if r.Status == "error" {
for _, c := range m.cfg.TrackedCompanies {
if c.Name == r.Name {
targets = append(targets, c)
m.rows[i].Status = "running"
m.rows[i].Err = ""
break
}
}
}
}
if len(targets) == 0 {
m.status = "no errored rows to retry"
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
m.cancel = cancel
runner := scan.NewRunner(m.db, m.cfg)
m.resultsCh = runner.Run(ctx, targets)
m.running = true
m.status = fmt.Sprintf("retrying %d errored rows…", len(targets))
m.refreshTable()
return m.pump()
}
func (m *ScanModel) pump() tea.Cmd {
return func() tea.Msg {
res, ok := <-m.resultsCh
if !ok {
return scanDoneMsg{}
}
return scanResultMsg(res)
}
}
func (m *ScanModel) applyResult(res scan.ScanResult) {
for i := range m.rows {
if m.rows[i].Name != res.Company {
continue
}
if res.Err != nil {
if scan.IsUnsupported(res.Err) {
m.rows[i].Status = "unsupported"
} else {
m.rows[i].Status = "error"
m.rows[i].Err = res.Err.Error()
}
return
}
m.rows[i].Status = "done"
m.rows[i].Found = res.Found
m.rows[i].Filtered = res.Filtered
m.rows[i].Duplicate = res.Duplicate
m.rows[i].New = res.New
return
}
}
func (m *ScanModel) refreshTable() {
newRows := make([]table.Row, 0, len(m.rows))
for _, r := range m.rows {
notes := r.Err
if notes == "" && r.Status == "unsupported" {
notes = "no tier-1 API"
}
newRows = append(newRows, table.Row{
r.Name, r.Status, intStr(r.Found), intStr(r.Filtered),
intStr(r.Duplicate), intStr(r.New), notes,
})
}
m.table.SetRows(newRows)
}
func (m *ScanModel) summary() string {
var found, newCnt, dup, err, unsup int
for _, r := range m.rows {
found += r.Found
newCnt += r.New
dup += r.Duplicate
switch r.Status {
case "error":
err++
case "unsupported":
unsup++
}
}
return fmt.Sprintf("done — %d found · %d new · %d dup · %d errors · %d unsupported", found, newCnt, dup, err, unsup)
}
func (m *ScanModel) View() string {
if m.cfg == nil {
return StyleTitle.Render("Scan") + "\n" + StyleStatusErr.Render(m.status)
}
header := StyleTitle.Render("Scan — Tier-1 API adapters")
sub := StyleSubtitle.Render(fmt.Sprintf("%d companies · [s] scan · [r] retry errored · [enter] detail · [esc] cancel", len(m.rows)))
status := StyleStatusMuted.Render(m.status)
view := lipgloss.JoinVertical(lipgloss.Left, header, sub, "", m.table.View(), "", status)
if m.showDetail && m.detailIdx < len(m.rows) {
overlay := m.renderDetail(m.rows[m.detailIdx])
return lipgloss.JoinVertical(lipgloss.Left, view, overlay)
}
return view
}
func (m *ScanModel) renderDetail(r scanRow) string {
var b strings.Builder
b.WriteString(StyleTitle.Render("Row detail — " + r.Name))
b.WriteString("\n\n")
b.WriteString(kv("Status", r.Status))
b.WriteString(kv("Found", fmt.Sprintf("%d", r.Found)))
b.WriteString(kv("Filtered", fmt.Sprintf("%d", r.Filtered)))
b.WriteString(kv("Duplicate", fmt.Sprintf("%d", r.Duplicate)))
b.WriteString(kv("New", fmt.Sprintf("%d", r.New)))
if r.Err != "" {
b.WriteString("\n")
b.WriteString(StyleStatusErr.Render("Error: " + r.Err))
b.WriteString("\n")
}
b.WriteString("\n" + StyleKeyDesc.Render("press enter or esc to close"))
return StyleModal.Render(b.String())
}
func kv(k, v string) string {
return StyleKey.Render(fmt.Sprintf("%-12s", k)) + StyleKeyDesc.Render(v) + "\n"
}
+185
View File
@@ -0,0 +1,185 @@
package tui
import (
"database/sql"
"fmt"
"os"
"os/exec"
"github.com/cobr-ai/apex/internal/scan"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type SettingsModel struct {
db *sql.DB
scanCfgPath string
cursor int
items []settingRow
status string
width int
height int
}
type settingRow struct {
Label string
Value string
Kind string
}
func NewSettingsModel(db *sql.DB, scanCfgPath string) *SettingsModel {
m := &SettingsModel{db: db, scanCfgPath: scanCfgPath, status: "[↑/↓] move [enter] edit externally [r] refresh"}
m.load()
return m
}
func (m *SettingsModel) load() {
m.items = nil
claudePath, _ := exec.LookPath("claude")
claudeStatus := "not found"
if claudePath != "" {
claudeStatus = claudePath
}
m.items = append(m.items, settingRow{"Claude CLI", claudeStatus, "info"})
apexDir, _ := os.UserHomeDir()
dbPath := apexDir + "/.apex/apex.db"
dbInfo := dbPath
if st, err := os.Stat(dbPath); err == nil {
dbInfo = fmt.Sprintf("%s (%d KB)", dbPath, st.Size()/1024)
}
m.items = append(m.items, settingRow{"Database", dbInfo, "info"})
m.items = append(m.items, settingRow{"Adapters YAML", m.scanCfgPath, "info"})
if cfg, err := scan.LoadConfig(m.scanCfgPath); err == nil {
enabled := 0
for _, c := range cfg.TrackedCompanies {
if c.Enabled {
enabled++
}
}
m.items = append(m.items, settingRow{"Companies", fmt.Sprintf("%d total · %d enabled", len(cfg.TrackedCompanies), enabled), "info"})
}
if m.db != nil {
var jobs int
_ = m.db.QueryRow("SELECT COUNT(*) FROM jobs WHERE archived=0").Scan(&jobs)
m.items = append(m.items, settingRow{"Jobs in DB", fmt.Sprintf("%d", jobs), "info"})
var apps int
_ = m.db.QueryRow("SELECT COUNT(*) FROM applications").Scan(&apps)
m.items = append(m.items, settingRow{"Applications", fmt.Sprintf("%d", apps), "info"})
var reports int
_ = m.db.QueryRow("SELECT COUNT(*) FROM reports").Scan(&reports)
m.items = append(m.items, settingRow{"Reports", fmt.Sprintf("%d", reports), "info"})
}
infisicalStatus := "not configured"
if os.Getenv("INFISICAL_TOKEN") != "" {
infisicalStatus = "INFISICAL_TOKEN set"
} else if _, err := exec.LookPath("creds"); err == nil {
infisicalStatus = "creds CLI available"
}
m.items = append(m.items, settingRow{"Infisical", infisicalStatus, "info"})
m.items = append(m.items, settingRow{"Editor", editor(), "info"})
minScore := os.Getenv("APEX_MIN_SCORE")
if minScore == "" {
minScore = "0 (default)"
}
m.items = append(m.items, settingRow{"APEX_MIN_SCORE", minScore, "info"})
}
func editor() string {
if v := os.Getenv("VISUAL"); v != "" {
return v
}
if v := os.Getenv("EDITOR"); v != "" {
return v
}
return "vi (default)"
}
func (m *SettingsModel) Init() tea.Cmd { return nil }
func (m *SettingsModel) Resize(w, h int) {
m.width = w
m.height = h
}
func (m *SettingsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.items)-1 {
m.cursor++
}
case "r":
m.load()
if m.cursor >= len(m.items) {
m.cursor = len(m.items) - 1
}
case "enter":
if m.cursor < len(m.items) {
m.openEditor(m.items[m.cursor])
}
}
}
return m, nil
}
func (m *SettingsModel) openEditor(row settingRow) {
var path string
switch row.Label {
case "Adapters YAML":
path = m.scanCfgPath
case "Database":
m.status = "database is binary — open with sqlite3"
return
default:
m.status = fmt.Sprintf("no editable file for %q", row.Label)
return
}
ed := os.Getenv("VISUAL")
if ed == "" {
ed = os.Getenv("EDITOR")
}
if ed == "" {
ed = "vi"
}
cmd := exec.Command(ed, path)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Run()
m.load()
}
func (m *SettingsModel) View() string {
header := StyleTitle.Render("Settings — apex config + environment")
sub := StyleSubtitle.Render(fmt.Sprintf("%d items · [↑/↓] move · [enter] edit (adapters) · [r] refresh", len(m.items)))
var lines []string
for i, it := range m.items {
marker := " "
label := it.Label
if i == m.cursor {
marker = "▌ "
label = lipgloss.NewStyle().Bold(true).Foreground(ColorAccentAlt).Render(it.Label)
}
line := fmt.Sprintf("%s%-18s %s", marker, label, StyleKeyDesc.Render(it.Value))
lines = append(lines, line)
}
body := lipgloss.JoinVertical(lipgloss.Left, lines...)
status := StyleStatusMuted.Render(m.status)
return lipgloss.JoinVertical(lipgloss.Left, header, sub, "", body, "", status)
}
+84
View File
@@ -0,0 +1,84 @@
package tui
import "github.com/charmbracelet/lipgloss"
var (
ColorAccent = lipgloss.Color("6")
ColorAccentAlt = lipgloss.Color("14")
ColorMuted = lipgloss.Color("8")
ColorText = lipgloss.Color("15")
ColorSuccess = lipgloss.Color("10")
ColorWarn = lipgloss.Color("11")
ColorError = lipgloss.Color("9")
ColorBorder = lipgloss.Color("238")
ColorHighlight = lipgloss.Color("236")
StyleTitle = lipgloss.NewStyle().
Bold(true).
Foreground(ColorAccentAlt).
Padding(0, 1)
StyleSubtitle = lipgloss.NewStyle().
Foreground(ColorMuted).
Padding(0, 1)
StyleSidebarItem = lipgloss.NewStyle().
Padding(0, 1)
StyleSidebarActive = lipgloss.NewStyle().
Padding(0, 1).
Bold(true).
Foreground(ColorText).
Background(ColorAccent)
StyleSidebarCursor = lipgloss.NewStyle().
Padding(0, 1).
Foreground(ColorAccentAlt).
Bold(true)
StyleSidebarBox = lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(ColorBorder).
Padding(0, 1).
MarginRight(1)
StyleContentBox = lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(ColorBorder).
Padding(0, 1)
StyleFooter = lipgloss.NewStyle().
Foreground(ColorMuted).
Padding(0, 1)
StyleKey = lipgloss.NewStyle().
Foreground(ColorAccentAlt).
Bold(true)
StyleKeyDesc = lipgloss.NewStyle().
Foreground(ColorMuted)
StyleTableHeader = lipgloss.NewStyle().
Bold(true).
Foreground(ColorAccentAlt).
BorderStyle(lipgloss.NormalBorder()).
BorderBottom(true).
BorderForeground(ColorBorder)
StyleTableRowSelected = lipgloss.NewStyle().
Foreground(ColorText).
Background(ColorHighlight).
Bold(true)
StyleStatusOK = lipgloss.NewStyle().Foreground(ColorSuccess)
StyleStatusErr = lipgloss.NewStyle().Foreground(ColorError)
StyleStatusWarn = lipgloss.NewStyle().Foreground(ColorWarn)
StyleStatusMuted = lipgloss.NewStyle().Foreground(ColorMuted)
StyleStatusGhost = lipgloss.NewStyle().Foreground(lipgloss.Color("13")).Bold(true)
StyleModal = lipgloss.NewStyle().
BorderStyle(lipgloss.DoubleBorder()).
BorderForeground(ColorAccentAlt).
Padding(1, 2).
Background(lipgloss.Color("0"))
)
+320
View File
@@ -0,0 +1,320 @@
package tui
import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/cobr-ai/apex/internal/tracker"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const trackerDailyCap = 25
type TrackerModel struct {
mgr *tracker.Manager
db *sql.DB
table table.Model
apps []tracker.Application
stats tracker.Stats
width int
height int
status string
mode trackerMode
advanceTo []tracker.Status
advanceCur int
noteInput textinput.Model
pending int
}
type trackerMode int
const (
tmList trackerMode = iota
tmAdvanceMenu
tmNoteEntry
)
func NewTrackerModel(db *sql.DB) *TrackerModel {
mgr := tracker.NewManager(db, trackerDailyCap, 7*24*time.Hour)
ti := textinput.New()
ti.Placeholder = "note (optional)…"
ti.CharLimit = 200
ti.Width = 60
m := &TrackerModel{db: db, mgr: mgr, noteInput: ti, status: "[r] refresh [a] advance [n] note [f] follow-ups"}
m.refresh()
m.table = newTrackerTable(m.apps, 0, 0)
return m
}
func (m *TrackerModel) refresh() {
apps, err := m.mgr.List("")
if err != nil {
m.status = fmt.Sprintf("list failed: %v", err)
return
}
m.apps = apps
stats, _ := m.mgr.Stats()
m.stats = stats
}
func trackerColumns() []table.Column {
return []table.Column{
{Title: "ID", Width: 4},
{Title: "Company", Width: 20},
{Title: "Title", Width: 28},
{Title: "Status", Width: 11},
{Title: "Score", Width: 5},
{Title: "Applied", Width: 10},
{Title: "Updated", Width: 10},
}
}
func newTrackerTable(rows []tracker.Application, w, h int) table.Model {
cols := flexColumns(w, trackerColumns(), []int{1, 2})
trows := make([]table.Row, 0, len(rows))
for _, a := range rows {
trows = append(trows, trackerRowToTable(a))
}
t := table.New(
table.WithColumns(cols),
table.WithRows(trows),
table.WithFocused(true),
table.WithHeight(tableHeight(h)),
)
s := table.DefaultStyles()
s.Header = s.Header.BorderStyle(lipgloss.NormalBorder()).
BorderForeground(ColorBorder).BorderBottom(true).Bold(true).Foreground(ColorAccentAlt)
s.Selected = s.Selected.Bold(true).Foreground(ColorText).Background(ColorHighlight)
t.SetStyles(s)
return t
}
func trackerRowToTable(a tracker.Application) table.Row {
score := "-"
if a.Score.Valid && a.Score.Float64 > 0 {
score = fmt.Sprintf("%.1f", a.Score.Float64)
}
applied := "-"
if a.AppliedAt.Valid {
applied = a.AppliedAt.Time.Local().Format("2006-01-02")
}
updated := "-"
if a.UpdatedAt.Valid {
updated = a.UpdatedAt.Time.Local().Format("2006-01-02")
}
return table.Row{
fmt.Sprintf("%d", a.ID),
a.Company,
a.Title,
string(a.Status), score, applied, updated,
}
}
func (m *TrackerModel) Init() tea.Cmd { return nil }
func (m *TrackerModel) Resize(w, h int) {
m.width = w
m.height = h
m.table.SetHeight(tableHeight(h))
m.table.SetWidth(w - 2)
m.table.SetColumns(flexColumns(w-2, trackerColumns(), []int{1, 2}))
}
func (m *TrackerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch m.mode {
case tmAdvanceMenu:
return m.updateAdvanceMenu(msg)
case tmNoteEntry:
return m.updateNoteEntry(msg)
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "r":
m.refresh()
m.refreshTable()
m.status = fmt.Sprintf("%d apps · %d applied last 24h · %d pending follow-ups",
m.stats.Total, m.stats.AppliedLast24h, m.stats.OpenFollowUps)
case "a":
if cur := m.table.Cursor(); cur < len(m.apps) {
next := tracker.AllowedNext(m.apps[cur].Status)
if len(next) == 0 {
m.status = fmt.Sprintf("%s is terminal — no further moves", m.apps[cur].Status)
return m, nil
}
m.advanceTo = next
m.advanceCur = 0
m.mode = tmAdvanceMenu
m.status = "select target status (↑/↓ enter, esc cancel)"
}
case "n":
if cur := m.table.Cursor(); cur < len(m.apps) {
m.noteInput.SetValue("")
m.noteInput.Focus()
m.mode = tmNoteEntry
m.status = fmt.Sprintf("note for app #%d (enter to save, esc to cancel)", m.apps[cur].ID)
}
case "f":
pending, err := m.mgr.PendingFollowUps()
if err != nil {
m.status = fmt.Sprintf("follow_ups: %v", err)
return m, nil
}
m.pending = len(pending)
m.status = fmt.Sprintf("%d pending follow-ups", m.pending)
}
}
var cmd tea.Cmd
m.table, cmd = m.table.Update(msg)
return m, cmd
}
func (m *TrackerModel) updateAdvanceMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
if k, ok := msg.(tea.KeyMsg); ok {
switch k.String() {
case "esc", "q":
m.mode = tmList
m.status = "cancelled"
return m, nil
case "up", "k":
if m.advanceCur > 0 {
m.advanceCur--
}
case "down", "j":
if m.advanceCur < len(m.advanceTo)-1 {
m.advanceCur++
}
case "enter":
if cur := m.table.Cursor(); cur < len(m.apps) {
app := m.apps[cur]
target := m.advanceTo[m.advanceCur]
if err := m.mgr.Advance(app.ID, target, ""); err != nil {
switch {
case errors.Is(err, tracker.ErrRateLimited):
m.status = fmt.Sprintf("rate limit hit: %v", err)
case errors.Is(err, tracker.ErrInvalidTransition):
m.status = fmt.Sprintf("invalid: %v", err)
default:
m.status = fmt.Sprintf("advance failed: %v", err)
}
} else {
m.status = fmt.Sprintf("#%d %s → %s", app.ID, app.Status, target)
m.refresh()
m.refreshTable()
}
}
m.mode = tmList
}
}
return m, nil
}
func (m *TrackerModel) updateNoteEntry(msg tea.Msg) (tea.Model, tea.Cmd) {
if k, ok := msg.(tea.KeyMsg); ok {
switch k.String() {
case "esc":
m.mode = tmList
m.status = "note cancelled"
return m, nil
case "enter":
if cur := m.table.Cursor(); cur < len(m.apps) {
note := strings.TrimSpace(m.noteInput.Value())
if note == "" {
m.status = "empty note — discarded"
} else if err := m.mgr.AddNote(m.apps[cur].ID, note); err != nil {
m.status = fmt.Sprintf("add note failed: %v", err)
} else {
m.status = fmt.Sprintf("note added to #%d", m.apps[cur].ID)
}
}
m.mode = tmList
return m, nil
}
}
var cmd tea.Cmd
m.noteInput, cmd = m.noteInput.Update(msg)
return m, cmd
}
func (m *TrackerModel) refreshTable() {
trows := make([]table.Row, 0, len(m.apps))
for _, a := range m.apps {
trows = append(trows, trackerRowToTable(a))
}
m.table.SetRows(trows)
}
func (m *TrackerModel) View() string {
header := StyleTitle.Render("Tracker — application pipeline")
statsLine := fmt.Sprintf("%d apps · applied %d/%d (24h) · %d pending follow-ups",
m.stats.Total, m.stats.AppliedLast24h, trackerDailyCap, m.stats.OpenFollowUps)
sub := StyleSubtitle.Render(statsLine)
var body string
switch m.mode {
case tmAdvanceMenu:
body = m.renderAdvanceMenu()
case tmNoteEntry:
body = m.renderNoteEntry()
default:
body = m.table.View() + "\n\n" + m.renderStatusBreakdown()
}
status := StyleStatusMuted.Render(m.status)
return lipgloss.JoinVertical(lipgloss.Left, header, sub, "", body, "", status)
}
func (m *TrackerModel) renderAdvanceMenu() string {
var b strings.Builder
cur := m.table.Cursor()
if cur >= len(m.apps) {
return "no row selected"
}
app := m.apps[cur]
b.WriteString(fmt.Sprintf("Advance #%d (%s — %s)\n", app.ID, app.Company, app.Title))
b.WriteString(fmt.Sprintf("Current: %s\n\n", app.Status))
for i, s := range m.advanceTo {
marker := " "
line := string(s)
if i == m.advanceCur {
marker = "▌ "
line = lipgloss.NewStyle().Bold(true).Foreground(ColorAccentAlt).Render(line)
}
b.WriteString(marker + line + "\n")
}
b.WriteString("\n" + StyleKeyDesc.Render("[↑/↓] move [enter] confirm [esc] cancel"))
return StyleModal.Render(b.String())
}
func (m *TrackerModel) renderNoteEntry() string {
var b strings.Builder
cur := m.table.Cursor()
if cur < len(m.apps) {
app := m.apps[cur]
b.WriteString(fmt.Sprintf("Add note to #%d (%s — %s)\n\n", app.ID, app.Company, app.Title))
}
b.WriteString(m.noteInput.View())
b.WriteString("\n\n" + StyleKeyDesc.Render("[enter] save [esc] cancel"))
return StyleModal.Render(b.String())
}
func (m *TrackerModel) renderStatusBreakdown() string {
if len(m.stats.ByStatus) == 0 {
return ""
}
var parts []string
for _, s := range tracker.AllStatuses {
n := m.stats.ByStatus[s]
if n == 0 {
continue
}
parts = append(parts, fmt.Sprintf("%s %d", string(s), n))
}
return StyleStatusMuted.Render("breakdown — " + strings.Join(parts, " · "))
}