Download
678 lines 19.1 KiBGoModified 2026-10-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678
package tui

import (
	"bytes"
	"context"
	"crypto/sha256"
	"crypto/tls"
	"crypto/x509"
	"encoding/pem"
	"errors"
	"fmt"
	"net"
	"net/url"
	"path/filepath"
	"slices"
	"strconv"
	"strings"
	"time"

	"charm.land/bubbles/v2/spinner"
	"charm.land/bubbles/v2/textinput"
	tea "charm.land/bubbletea/v2"

	"blinker/internal/config"
	"blinker/internal/llm"
)

// preset is a kind of server, with the settings it usually needs.
type preset struct {
	label, url, model string
	apply             func(*config.Config)
}

var presets = []preset{
	{"Anthropic (Claude)", "https://api.anthropic.com/v1", "claude-opus-5-5", func(c *config.Config) {
		// Current Claude models reject sampling settings and think as they see fit.
		c.Sampling, c.ThinkingParam, c.MaxTokensParam = false, "none", "max_tokens"
	}},
	{"OpenAI", "https://api.openai.com/v1", "", func(c *config.Config) {
		c.Sampling, c.ThinkingParam, c.MaxTokensParam = false, "none", "max_completion_tokens"
	}},
	{"llama.cpp server", "http://localhost:8080/v1", "", func(c *config.Config) {
		c.Sampling, c.ThinkingParam, c.MaxTokensParam, c.TopK = true, "chat_template_kwargs", "max_tokens", 20
	}},
	{"vLLM or SGLang", "http://localhost:8000/v1", "", func(c *config.Config) {
		c.Sampling, c.ThinkingParam, c.MaxTokensParam, c.TopK = true, "chat_template_kwargs", "max_tokens", 20
	}},
	{"Ollama", "http://localhost:11434/v1", "", func(c *config.Config) {
		c.Sampling, c.ThinkingParam, c.MaxTokensParam, c.TopK = true, "none", "max_tokens", 0
	}},
	{"Other OpenAI-compatible server", "http://localhost:8080/v1", "", func(c *config.Config) {
		c.Sampling, c.ThinkingParam, c.MaxTokensParam, c.TopK = true, "none", "max_tokens", 0
	}},
}

type setupStep int

const (
	stepPreset setupStep = iota
	stepURL
	stepKey
	stepChecking // TLS check, then listing models
	stepTrust
	stepModel
	stepThinking
	stepTesting
	stepContext
	stepFailed
)

type (
	tlsCheckedMsg struct {
		certs []*x509.Certificate
		why   error
	}
	modelsMsg struct {
		models []string
		err    error
	}
	testedMsg struct {
		err    error
		window int // the context size the server gave; 0 if it did not
	}
	setupCancel struct{}
)

// setupModel asks for the LLM backend settings, checks them with a test
// request, and saves them.
type setupModel struct {
	cfg       config.Config
	path      string // the agent's settings file, which holds the key
	configDir string // where the settings and trusted certificates go
	step      setupStep
	sel       int
	url, key  textinput.Model
	filter    textinput.Model
	window    textinput.Model
	models    []string
	modelsErr error
	certs     []*x509.Certificate
	certWhy   error
	trustErr  error // why trusting the anchor would not help, checked once
	errText   string
	width     int
	spin      spinner.Model
	newClient func(config.Config) (*llm.Client, error)
}

func newSetup(cfg config.Config, configDir string, newClient func(config.Config) (*llm.Client, error)) setupModel {
	urlIn := textinput.New()
	urlIn.SetValue(cfg.BaseURL)
	urlIn.CursorEnd()
	keyIn := textinput.New()
	keyIn.EchoMode = textinput.EchoPassword
	keyIn.Placeholder = "empty: use $OPENAI_API_KEY ($ANTHROPIC_API_KEY for Anthropic), or no key"
	keyIn.SetValue(cfg.APIKey)
	keyIn.CursorEnd()
	filterIn := textinput.New()
	filterIn.Placeholder = "type to filter, or a model name"
	windowIn := textinput.New()
	windowIn.Placeholder = "empty: unknown, so compact only when asked (/compact)"
	if cfg.ContextWindow > 0 {
		windowIn.SetValue(strconv.Itoa(cfg.ContextWindow))
		windowIn.CursorEnd()
	}
	for _, in := range []*textinput.Model{&urlIn, &keyIn, &filterIn, &windowIn} {
		in.SetWidth(76)
	}
	s := setupModel{cfg: cfg, path: filepath.Join(configDir, config.AgentFile), configDir: configDir, url: urlIn, key: keyIn, filter: filterIn, window: windowIn,
		width: 80, spin: spinner.New(spinner.WithSpinner(clockwiseDot)), newClient: newClient}
	s.sel = s.presetIndex()
	return s
}

func (s setupModel) Init() tea.Cmd { return s.spin.Tick }

func (s setupModel) checkTLS() tea.Cmd {
	cfg := s.cfg
	return func() tea.Msg {
		certs, why := untrusted(cfg, cfg.BaseURL)
		return tlsCheckedMsg{certs, why}
	}
}

func (s setupModel) listModels() tea.Cmd {
	c, err := s.newClient(s.cfg)
	return func() tea.Msg {
		if err != nil {
			return modelsMsg{err: err}
		}
		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
		defer cancel()
		m, err := c.Models(ctx)
		return modelsMsg{m, err}
	}
}

// test makes the kind of request blinker will, streaming, with a tool, and
// asks the server the model's context size.
func (s setupModel) test() tea.Cmd {
	cfg := s.cfg
	cfg.Thinking, cfg.MaxTokens = false, 64 // quick; the switch is still sent
	c, err := s.newClient(cfg)
	return func() tea.Msg {
		if err != nil {
			return testedMsg{err: err}
		}
		ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
		defer cancel()
		ping := llm.Tool{Name: "ping", Description: "Returns pong.", Parameters: map[string]any{"type": "object", "properties": map[string]any{}}}
		_, err := c.Complete(ctx, []llm.Message{{Role: "user", Content: "Reply with just the word OK."}}, []llm.Tool{ping}, nil)
		if errors.Is(err, llm.ErrTruncated) {
			err = nil // the server took the request
		}
		if err != nil {
			return testedMsg{err: err}
		}
		window, err := c.ContextSize(ctx)
		if err != nil {
			window = 0 // the server does not say; setup asks
		}
		return testedMsg{window: window}
	}
}

func (s setupModel) filtered() []string {
	f := strings.ToLower(strings.TrimSpace(s.filter.Value()))
	var out []string
	for _, m := range s.models {
		if strings.Contains(strings.ToLower(m), f) {
			out = append(out, m)
		}
	}
	return out
}

func (s setupModel) Update(msg tea.Msg) (setupModel, tea.Cmd) {
	switch msg := msg.(type) {
	case spinner.TickMsg:
		var cmd tea.Cmd
		s.spin, cmd = s.spin.Update(msg)
		return s, cmd
	case tlsCheckedMsg:
		if len(msg.certs) > 0 {
			s.certs, s.certWhy, s.step, s.sel = msg.certs, msg.why, stepTrust, 0
			s.trustErr = checkAnchor(s.certs, anchor(s.certs), hostOf(s.cfg.BaseURL))
			return s, nil
		}
		if msg.why != nil {
			s.errText, s.step = msg.why.Error(), stepURL
			return s, s.url.Focus()
		}
		return s, s.listModels()
	case modelsMsg:
		s.models, s.modelsErr = msg.models, msg.err
		s.step, s.sel = stepModel, 0
		s.filter.SetValue("")
		if s.cfg.Model != "" && !slices.Contains(s.models, s.cfg.Model) {
			s.filter.SetValue(s.cfg.Model)
			s.filter.CursorEnd()
		}
		for i, m := range s.models {
			if m == s.cfg.Model || (s.cfg.Model == "" && m == presets[s.presetIndex()].model) {
				s.sel = i
			}
		}
		return s, s.filter.Focus()
	case testedMsg:
		if msg.err != nil {
			s.errText, s.step, s.sel = msg.err.Error(), stepFailed, 0
			return s, nil
		}
		if msg.window > 0 {
			s.cfg.ContextWindow = 0 // ask the server each time, in case it changes
			return s.save()
		}
		s.step = stepContext
		return s, s.window.Focus()
	case tea.KeyPressMsg:
		return s.keyPress(msg)
	}
	// Anything else, such as pasted text (from the terminal, or from the
	// clipboard with ctrl+v), goes to the field being edited.
	var cmd tea.Cmd
	switch s.step {
	case stepURL:
		s.url, cmd = s.url.Update(msg)
	case stepKey:
		s.key, cmd = s.key.Update(msg)
	case stepContext:
		s.window, cmd = s.window.Update(msg)
	case stepModel:
		s.filter, cmd = s.filter.Update(msg)
		s.sel = 0
	}
	return s, cmd
}

// presetIndex is the preset matching the URL, or "other".
func (s setupModel) presetIndex() int {
	for i, p := range presets {
		if p.url == s.cfg.BaseURL {
			return i
		}
	}
	return len(presets) - 1
}

func (s setupModel) save() (setupModel, tea.Cmd) {
	if err := s.cfg.Save(s.configDir); err != nil {
		s.errText, s.step = "Saving: "+err.Error(), stepFailed
		return s, nil
	}
	cfg := s.cfg
	return s, func() tea.Msg { return setupDoneMsg{cfg} }
}

func (s setupModel) keyPress(msg tea.KeyPressMsg) (setupModel, tea.Cmd) {
	k := msg.String()
	if k == "ctrl+c" {
		return s, func() tea.Msg { return setupCancel{} }
	}
	switch s.step {
	case stepPreset:
		return s.presetKey(k)
	case stepURL:
		return s.urlKey(k, msg)
	case stepKey:
		return s.apiKeyKey(k, msg)
	case stepTrust:
		return s.trustKey(k)
	case stepModel:
		return s.modelKey(k, msg)
	case stepThinking:
		return s.thinkingKey(k)
	case stepContext:
		return s.windowKey(k, msg)
	case stepFailed:
		return s.failedKey(k)
	}
	return s, nil
}

// choose moves the selection among n options for key k, and reports
// whether k picked the selected one.
func (s *setupModel) choose(k string, n int) bool {
	switch k {
	case "up", "shift+tab":
		s.sel = (s.sel + n - 1) % n
	case "down", "tab":
		s.sel = (s.sel + 1) % n
	case "enter":
		return true
	}
	return false
}

func (s setupModel) presetKey(k string) (setupModel, tea.Cmd) {
	if k == "esc" {
		return s, func() tea.Msg { return setupCancel{} }
	}
	if !s.choose(k, len(presets)) {
		return s, nil
	}
	p := presets[s.sel]
	if p.url != s.cfg.BaseURL {
		s.cfg.BaseURL, s.cfg.Model, s.cfg.APIKey = p.url, "", ""
		s.key.SetValue("")
	}
	p.apply(&s.cfg)
	s.url.SetValue(s.cfg.BaseURL)
	s.url.CursorEnd()
	s.step, s.errText = stepURL, ""
	return s, s.url.Focus()
}

func (s setupModel) urlKey(k string, msg tea.KeyPressMsg) (setupModel, tea.Cmd) {
	switch k {
	case "esc":
		s.step = stepPreset
		return s, nil
	case "enter":
		v := strings.TrimSuffix(strings.TrimSpace(s.url.Value()), "/")
		u, err := url.Parse(v)
		if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
			s.errText = "Enter an http:// or https:// URL."
			return s, nil
		}
		if v != s.cfg.BaseURL {
			s.cfg.CACert = ""
		}
		s.cfg.BaseURL, s.errText, s.step = v, "", stepKey
		s.url.Blur()
		return s, s.key.Focus()
	}
	var cmd tea.Cmd
	s.url, cmd = s.url.Update(msg)
	return s, cmd
}

func (s setupModel) apiKeyKey(k string, msg tea.KeyPressMsg) (setupModel, tea.Cmd) {
	switch k {
	case "esc":
		s.step = stepURL
		return s, s.url.Focus()
	case "enter":
		s.cfg.APIKey = strings.TrimSpace(s.key.Value())
		s.key.Blur()
		s.step = stepChecking
		return s, tea.Batch(s.checkTLS(), s.spin.Tick)
	}
	var cmd tea.Cmd
	s.key, cmd = s.key.Update(msg)
	return s, cmd
}

func (s setupModel) trustKey(k string) (setupModel, tea.Cmd) {
	if k == "esc" {
		s.step = stepURL
		return s, s.url.Focus()
	}
	if !s.choose(k, len(s.trustOptions())) {
		return s, nil
	}
	if s.trustErr != nil || s.sel != 0 { // not "Trust it"
		s.step = stepURL
		return s, s.url.Focus()
	}
	path, err := trustCert(s.configDir, hostOf(s.cfg.BaseURL), anchor(s.certs))
	if err != nil {
		s.errText = err.Error()
		return s, nil
	}
	s.cfg.CACert = path
	s.step = stepChecking
	return s, tea.Batch(s.listModels(), s.spin.Tick)
}

func (s setupModel) modelKey(k string, msg tea.KeyPressMsg) (setupModel, tea.Cmd) {
	list := s.filtered()
	switch k {
	case "esc":
		s.step = stepKey
		return s, s.key.Focus()
	case "up", "down":
		if len(list) > 0 {
			s.choose(k, len(list))
		}
		return s, nil
	case "enter":
		name := strings.TrimSpace(s.filter.Value())
		if len(list) > 0 && s.sel < len(list) {
			name = list[s.sel]
		}
		if name == "" {
			s.errText = "Choose or type a model."
			return s, nil
		}
		s.cfg.Model, s.errText = name, ""
		s.toThinking()
		s.filter.Blur()
		return s, nil
	}
	var cmd tea.Cmd
	s.filter, cmd = s.filter.Update(msg)
	if n := len(s.filtered()); s.sel >= n {
		s.sel = max(n-1, 0)
	}
	return s, cmd
}

// toThinking goes to the question of thinking, with the answer set now
// selected.
func (s *setupModel) toThinking() {
	s.step, s.sel = stepThinking, 0
	if !s.cfg.Thinking {
		s.sel = 1
	}
}

func (s setupModel) thinkingKey(k string) (setupModel, tea.Cmd) {
	if k == "esc" {
		s.step = stepModel
		return s, s.filter.Focus()
	}
	if !s.choose(k, 2) {
		return s, nil
	}
	s.cfg.Thinking = s.sel == 0
	s.step = stepTesting
	return s, tea.Batch(s.test(), s.spin.Tick)
}

func (s setupModel) windowKey(k string, msg tea.KeyPressMsg) (setupModel, tea.Cmd) {
	switch k {
	case "esc":
		s.toThinking()
		s.window.Blur()
		return s, nil
	case "enter":
		v := strings.TrimSpace(s.window.Value())
		n := 0
		if v != "" {
			var err error
			n, err = strconv.Atoi(v)
			if err != nil || n < 1000 {
				s.errText = "Enter a number of tokens, such as 32768, or nothing."
				return s, nil
			}
		}
		s.cfg.ContextWindow, s.errText = n, ""
		s.window.Blur()
		return s.save()
	}
	var cmd tea.Cmd
	s.window, cmd = s.window.Update(msg)
	return s, cmd
}

func (s setupModel) failedKey(k string) (setupModel, tea.Cmd) {
	if !s.choose(k, 3) {
		return s, nil
	}
	switch s.sel {
	case 0:
		s.step, s.sel, s.errText = stepPreset, s.presetIndex(), ""
	case 1:
		return s.save()
	default:
		return s, func() tea.Msg { return setupCancel{} }
	}
	return s, nil
}

// trustOptions are the answers to whether to trust the server's
// certificate: trusting it first, if that would help, and going back.
func (s setupModel) trustOptions() []string {
	back := "Change the URL"
	if s.trustErr != nil {
		return []string{back}
	}
	return []string{"Trust it (saved in " + certPath(s.configDir, hostOf(s.cfg.BaseURL)) + ")", back}
}

func (s setupModel) View() string {
	var b strings.Builder
	b.WriteString(userStyle.Render("blinker-agent setup") + dimStyle.Render(" · "+s.path) + "\n\n")
	list := func(items []string) {
		for i, it := range items {
			b.WriteString(menuRow(it, i == s.sel))
		}
	}
	switch s.step {
	case stepPreset:
		b.WriteString("Which LLM backend should blinker use?\n")
		var labels []string
		for _, p := range presets {
			labels = append(labels, p.label)
		}
		list(labels)
	case stepURL:
		b.WriteString("Server URL (the OpenAI-compatible API, ending before /chat/completions):\n")
		b.WriteString(s.url.View() + "\n")
	case stepKey:
		b.WriteString("API key (saved in " + s.path + ", readable only by you):\n")
		b.WriteString(s.key.View() + "\n")
	case stepChecking, stepTesting:
		what := "Asking " + s.cfg.BaseURL + " what it offers…"
		if s.step == stepTesting {
			what = "Sending a test request to " + s.cfg.Model + "…"
		}
		b.WriteString(s.spin.View() + " " + Sanitize(what) + "\n")
	case stepTrust:
		b.WriteString(Sanitize(trustText(s.certs, s.certWhy, s.trustErr, s.cfg.BaseURL)) + "\n\n")
		list(s.trustOptions())
	case stepModel:
		if s.modelsErr != nil {
			b.WriteString(dimStyle.Render(wrap("Could not list the server's models: "+Sanitize(s.modelsErr.Error()), s.width)) + "\n")
		}
		b.WriteString("Model:\n" + s.filter.View() + "\n")
		items := s.filtered()
		start := max(0, min(s.sel-5, len(items)-10))
		end := min(start+10, len(items))
		for i := start; i < end; i++ {
			b.WriteString(menuRow(Sanitize(items[i]), i == s.sel))
		}
		if len(items) > 10 {
			b.WriteString(dimStyle.Render(fmt.Sprintf("  (%d models match)", len(items))) + "\n")
		}
	case stepThinking:
		b.WriteString("Let the model reason before it answers? (Slower, usually better.)\n")
		list([]string{"Yes", "No"})
	case stepContext:
		b.WriteString(wrap("The server does not say how much context "+Sanitize(s.cfg.Model)+" has. How many tokens can it take in? Blinker compacts the conversation when it nearly fills them.", s.width) + "\n")
		b.WriteString(s.window.View() + "\n")
	case stepFailed:
		b.WriteString(errStyle.Render("The test request failed:") + "\n")
		b.WriteString(wrap(Sanitize(s.errText), s.width) + "\n\n")
		list([]string{"Change the settings", "Save them anyway", "Quit without saving"})
	}
	if s.errText != "" && s.step != stepFailed {
		b.WriteString(errStyle.Render(wrap(Sanitize(s.errText), s.width)) + "\n")
	}
	b.WriteString("\n" + dimStyle.Render("enter to continue · esc to go back · ctrl+c to cancel"))
	return b.String()
}

// Trusting a server's certificate on first use, for local servers with
// self-signed or private-CA certificates.

func hostOf(rawURL string) string {
	u, err := url.Parse(rawURL)
	if err != nil {
		return ""
	}
	return u.Hostname()
}

// untrusted returns the certificates an https server presents if the
// config's TLS settings do not trust them, with the reason; nil if they
// do, or the URL is not https, or the server cannot be reached (later
// requests report that).
func untrusted(cfg config.Config, rawURL string) ([]*x509.Certificate, error) {
	u, err := url.Parse(rawURL)
	if err != nil || u.Scheme != "https" {
		return nil, nil
	}
	conf, err := llm.TLSConfig(cfg.CACert)
	if err != nil {
		return nil, err
	}
	addr := u.Host
	if u.Port() == "" {
		addr = net.JoinHostPort(u.Hostname(), "443")
	}
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	conf.ServerName = u.Hostname()
	conn, err := (&tls.Dialer{Config: conf}).DialContext(ctx, "tcp", addr)
	if err == nil {
		return nil, conn.Close()
	}
	var verifyErr *tls.CertificateVerificationError
	if !errors.As(err, &verifyErr) || len(verifyErr.UnverifiedCertificates) == 0 {
		return nil, nil
	}
	return verifyErr.UnverifiedCertificates, verifyErr.Err
}

// anchor picks the certificate to trust: the self-signed one at the top
// of the chain the server sent (its private CA, or its own certificate if
// it signed it itself), or else the server's certificate.
func anchor(certs []*x509.Certificate) *x509.Certificate {
	top := certs[len(certs)-1]
	if bytes.Equal(top.RawIssuer, top.RawSubject) && top.CheckSignatureFrom(top) == nil {
		return top
	}
	return certs[0]
}

// checkAnchor reports whether trusting the anchor makes the server's
// certificate valid for host.
func checkAnchor(certs []*x509.Certificate, root *x509.Certificate, host string) error {
	roots, inter := x509.NewCertPool(), x509.NewCertPool()
	roots.AddCert(root)
	for _, c := range certs[1:] {
		inter.AddCert(c)
	}
	_, err := certs[0].Verify(x509.VerifyOptions{DNSName: host, Roots: roots, Intermediates: inter})
	return err
}

// fingerprint is a certificate's SHA-256 fingerprint, as openssl x509
// -fingerprint -sha256 prints it.
func fingerprint(c *x509.Certificate) string {
	sum := sha256.Sum256(c.Raw)
	parts := make([]string, len(sum))
	for i, x := range sum {
		parts[i] = fmt.Sprintf("%02X", x)
	}
	return strings.Join(parts, ":")
}

func certPath(configDir, host string) string {
	return filepath.Join(configDir, "certs", host+".pem")
}

// trustCert saves a certificate to trust and returns its path.
func trustCert(configDir, host string, c *x509.Certificate) (string, error) {
	if host == "" || strings.ContainsAny(host, `/\`) {
		return "", fmt.Errorf("bad host %q", host)
	}
	path := certPath(configDir, host)
	data := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: c.Raw})
	if err := config.WriteFile(path, data, 0o644); err != nil {
		return "", err
	}
	return path, nil
}

func trustText(certs []*x509.Certificate, why, trustErr error, rawURL string) string {
	root, leaf := anchor(certs), certs[0]
	var names []string
	names = append(names, leaf.DNSNames...)
	for _, ip := range leaf.IPAddresses {
		names = append(names, ip.String())
	}
	kind := "the server's own certificate"
	if root != leaf {
		kind = "the CA that issued it, " + root.Subject.String()
	}
	text := fmt.Sprintf("%s presents a certificate blinker does not trust (%v).\n\nCertificate: %s, for %s, issued by %s, valid until %s.\n\nTrusting it saves %s, with SHA-256 fingerprint\n%s\nCompare that with `openssl x509 -noout -fingerprint -sha256 -in <the certificate>` on the server.",
		hostOf(rawURL), why, leaf.Subject, strings.Join(names, ", "), leaf.Issuer, leaf.NotAfter.Format("2 Jan 2006"), kind, fingerprint(root))
	if trustErr != nil {
		text += "\n\nTrusting it would not help: " + trustErr.Error()
	}
	return text
}