initial public release
This commit is contained in:
@@ -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()
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 "."
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"))
|
||||
)
|
||||
@@ -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, " · "))
|
||||
}
|
||||
Reference in New Issue
Block a user