Download
399 lines 14.1 KiBGoModified 2026-10-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
package tui

import (
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"reflect"
	"slices"
	"strconv"
	"strings"
	"time"
	"unicode/utf8"

	"charm.land/bubbles/v2/textinput"
	tea "charm.land/bubbletea/v2"
	"github.com/charmbracelet/x/ansi"

	"blinker/internal/agent"
	"blinker/internal/config"
	"blinker/internal/sandbox"
)

// A setting is one field of config.Config or config.Sandbox, named by its
// JSON key. How it is shown and edited follows the field's type: text, a
// number, a duration, a comma-separated list, on/off (toggled), or one of
// choices (cycled). Or it is a file in the config directory, edited in
// $EDITOR.
type setting struct {
	section string // a heading shown above the setting, if any
	key     string
	file    string // instead of key: the file's name
	label   string
	help    string
	unset   string    // shown for an empty string or list, or a zero integer
	words   [2]string // for on/off: what off and on are called, if not "off" and "on"
	choices []string  // for text: the only values allowed, cycled through
	spaced  bool      // for a list: its items are separated by spaces, not commas
}

var settings = []setting{
	{section: "Backend", key: "base_url", label: "Server URL", help: "The OpenAI-compatible API, ending before /chat/completions."},
	{key: "api_key", label: "API key", unset: "from the environment", help: "Empty: use $OPENAI_API_KEY ($ANTHROPIC_API_KEY for api.anthropic.com), or no key. Never shown."},
	{key: "model", label: "Model", unset: "none", help: "The model to use. /setup lists the server's models."},
	{key: "ca_cert", label: "Trusted certificate", unset: "the system's", help: "A PEM certificate file to trust for the API, as an absolute path. /setup saves one when you trust a server."},
	{key: "context_window", label: "Context window", unset: "ask the server", help: "The model's context size in tokens. Empty: ask the server, which vLLM, SGLang and llama.cpp answer."},
	{key: "request_timeout", label: "Request timeout", help: "Time limit for each request to the model, such as 30s or 10m."},
	{section: "Generation", key: "thinking", label: "Thinking", help: "Whether the model thinks before answering."},
	{key: "thinking_param", label: "Thinking switch", choices: []string{"chat_template_kwargs", "none"}, help: "How thinking is switched on and off: chat_template_kwargs (llama.cpp, vLLM, SGLang), or none for servers that decide themselves."},
	{key: "sampling", label: "Sampling settings", words: [2]string{"not sent", "sent"}, help: "Whether to send temperature, top p and top k. Some APIs refuse them."},
	{key: "temperature", label: "Temperature", help: "From 0 to 2. Lower is more predictable."},
	{key: "top_p", label: "Top p", help: "From 0 to 1: sample from the likeliest tokens that make up this much of the probability."},
	{key: "top_k", label: "Top k", unset: "no limit", help: "Sample from this many of the likeliest tokens. Empty: no limit."},
	{key: "max_tokens", label: "Max reply tokens", unset: "no limit", help: "Most tokens in each reply, reasoning included. Empty: no limit."},
	{key: "max_tokens_param", label: "Max tokens name", choices: []string{"max_tokens", "max_completion_tokens"}, help: "What the server calls the reply limit: max_tokens, or max_completion_tokens (OpenAI)."},
	{section: "Sandbox", key: "command_timeout", label: "Command timeout", help: "Default time limit for Safe_Bash and Unsafe_Bash, such as 30s or 5m."},
	{key: "proxy_ports", label: "Proxy ports", unset: "none", help: "Ports sandboxed commands may reach through the proxy, separated by commas."},
	{key: "allow_private_egress", label: "Private addresses", words: [2]string{"blocked", "allowed"}, help: "Whether sandboxed commands may reach private and local network addresses through the proxy."},
	{key: "extra_blocklist", label: "Unreadable paths", unset: "none", help: "More paths no command can read, whatever the policy says, separated by commas. Absolute or starting with ~/."},
	{key: "extra_never_write", label: "Unwritable paths", unset: "none", help: "More paths no command can write, whatever the policy says, separated by commas. Absolute or starting with ~/."},
	{key: "scratch_root", label: "Scratch directory", unset: "temp dir", help: "Where scratch directories go, as an absolute path. Empty: the system's temp dir."},
	{section: "Other", key: "diff_command", label: "Diff command", unset: "built in", spaced: true, help: "A program that colors diffs, and its arguments, such as delta --width={width}. Arguments cannot contain spaces."},
	{key: "log_file", label: "API log file", unset: "none", help: "A file to log API traffic to, with the key left out, as an absolute path."},
	{file: agent.InstructionsFile, label: "Your instructions", help: "Instructions for the model in every project, added to its system prompt, as a project's AGENTS.md is. Read when a conversation starts; /clear starts a new one."},
}

// showFile is how long a setting's file is. It is read as the agent
// reads it, so a FIFO or a device there cannot hang the screen.
func showFile(dir string, s setting) string {
	data, err := agent.ReadLimited(filepath.Join(dir, s.file), agent.MaxInstructions)
	switch {
	case errors.Is(err, fs.ErrNotExist):
		return "none"
	case errors.Is(err, sandbox.ErrTooLarge):
		return fmt.Sprintf("over %d KB", agent.MaxInstructions>>10)
	case err != nil:
		return "unreadable"
	}
	text := strings.TrimSpace(string(data))
	if text == "" {
		return "empty"
	}
	if n := strings.Count(text, "\n") + 1; n > 1 {
		return fmt.Sprintf("%d lines", n)
	}
	return "1 line"
}

// field is the field of c that key names.
func field(c *config.Config, key string) reflect.Value {
	v, _ := lookup(c, key)
	return v
}

// lookup is the field of c that key names, and the file it is saved in.
func lookup(c *config.Config, key string) (reflect.Value, string) {
	for _, s := range []struct {
		v    reflect.Value
		file string
	}{{reflect.ValueOf(c).Elem(), config.AgentFile}, {reflect.ValueOf(&c.Sandbox).Elem(), config.SandboxFile}} {
		for i := range s.v.NumField() {
			if k, _, _ := strings.Cut(s.v.Type().Field(i).Tag.Get("json"), ","); k == key && k != "-" {
				return s.v.Field(i), s.file
			}
		}
	}
	panic("no setting " + key) // TestSettingsCoverConfig checks every key
}

// show is a setting's value as people write it.
func show(c config.Config, s setting) string {
	switch v := field(&c, s.key).Interface().(type) {
	case string:
		if v != "" && s.key == "api_key" {
			return strings.Repeat("*", utf8.RuneCountInString(v))
		}
		return orUnset(v, s)
	case bool:
		words := s.words
		if words[0] == "" {
			words = [2]string{"off", "on"}
		}
		if v {
			return words[1]
		}
		return words[0]
	case int:
		if v == 0 && s.unset != "" {
			return s.unset
		}
		return strconv.Itoa(v)
	case float64:
		return strconv.FormatFloat(v, 'g', -1, 64)
	case config.Duration:
		d := time.Duration(v).String() // such as 1h0m0s
		if strings.HasSuffix(d, "m0s") {
			d = strings.TrimSuffix(d, "0s")
		}
		if strings.HasSuffix(d, "h0m") {
			d = strings.TrimSuffix(d, "0m")
		}
		return orUnset(d, s)
	case []string:
		if s.spaced {
			return orUnset(strings.Join(v, " "), s)
		}
		return orUnset(strings.Join(v, ", "), s)
	case []int:
		var parts []string
		for _, n := range v {
			parts = append(parts, strconv.Itoa(n))
		}
		return orUnset(strings.Join(parts, ", "), s)
	}
	return "?"
}

func orUnset(v string, s setting) string {
	if v == "" {
		return s.unset
	}
	return v
}

// edited is the text to start editing a setting with: its value, or
// nothing if it is unset or the API key.
func edited(c config.Config, s setting) string {
	v := field(&c, s.key)
	if s.key == "api_key" || v.IsZero() || v.Kind() == reflect.Slice && v.Len() == 0 {
		return ""
	}
	return show(c, s)
}

// set is c with a setting changed to what the user typed, checked as a
// loaded config file is.
func set(c config.Config, s setting, typed string) (config.Config, error) {
	typed = strings.TrimSpace(typed)
	f := field(&c, s.key)
	var list []string
	for item := range strings.SplitSeq(typed, ",") {
		if item = strings.TrimSpace(item); item != "" {
			list = append(list, item)
		}
	}
	if s.spaced {
		list = strings.Fields(typed)
	}
	switch f.Interface().(type) {
	case string:
		f.SetString(typed)
	case int:
		n := 0
		if typed != "" {
			var err error
			if n, err = strconv.Atoi(typed); err != nil {
				return c, fmt.Errorf("%s must be a whole number", s.label)
			}
		}
		f.SetInt(int64(n))
	case float64:
		x, err := strconv.ParseFloat(typed, 64)
		if err != nil {
			return c, fmt.Errorf("%s must be a number", s.label)
		}
		f.SetFloat(x)
	case config.Duration:
		d, err := time.ParseDuration(typed)
		if err != nil {
			return c, fmt.Errorf("%s must be a time such as 30s, 5m or 1h", s.label)
		}
		f.Set(reflect.ValueOf(config.Duration(d)))
	case []string:
		f.Set(reflect.ValueOf(list))
	case []int:
		var ns []int
		for _, item := range list {
			n, err := strconv.Atoi(item)
			if err != nil {
				return c, fmt.Errorf("%s must be whole numbers separated by commas, not %q", s.label, item)
			}
			ns = append(ns, n)
		}
		f.Set(reflect.ValueOf(ns))
	default:
		return c, fmt.Errorf("%s cannot be typed", s.label)
	}
	return c, c.Validate()
}

// valueColumn is where values start in a row: after "› " and the label.
const valueColumn = 2 + 20 + 1

// settingsModel is /config: the settings, edited one at a time, each
// change checked as a loaded file is and saved at once.
type settingsModel struct {
	cfg     config.Config
	dir     string // the config directory, holding the settings' files
	sel     int
	editing bool
	input   textinput.Model
	errText string
	saved   bool
	width   int
	files   map[string]string // each file setting's showFile, read in Update, not View
}

type settingsDone struct {
	saved bool
	cfg   config.Config
}

// settingsFileEdited is the editor finishing with a setting's file.
type settingsFileEdited struct{ err error }

func newSettings(cfg config.Config, dir string, width int) settingsModel {
	in := textinput.New()
	in.Prompt = "" // it takes the value's place in its row
	s := settingsModel{cfg: cfg, dir: dir, input: in, width: width}
	s.readFiles()
	return s
}

func (s *settingsModel) readFiles() {
	s.files = map[string]string{}
	for _, st := range settings {
		if st.file != "" {
			s.files[st.file] = showFile(s.dir, st)
		}
	}
}

// save saves next, or keeps the error to show.
func (s settingsModel) save(next config.Config, err error) settingsModel {
	if err == nil {
		err = next.Save(s.dir)
	}
	if err != nil {
		s.errText = err.Error()
		return s
	}
	s.cfg, s.saved, s.editing, s.errText = next, true, false, ""
	s.input.Blur()
	return s
}

func (s settingsModel) Update(msg tea.Msg) (settingsModel, tea.Cmd) {
	if e, ok := msg.(settingsFileEdited); ok {
		s.errText = ""
		if e.err != nil {
			s.errText = "Editor: " + e.err.Error()
		}
		s.readFiles()
		return s, nil
	}
	key, ok := msg.(tea.KeyPressMsg)
	if ok && key.String() == "ctrl+c" {
		// Closes the screen even mid-edit; the edit is not saved.
		done := settingsDone{s.saved, s.cfg}
		return s, func() tea.Msg { return done }
	}
	if !ok || s.editing && key.String() != "enter" && key.String() != "esc" {
		var cmd tea.Cmd
		if s.editing {
			s.input, cmd = s.input.Update(msg)
		}
		return s, cmd
	}
	st := settings[s.sel]
	switch key.String() {
	case "up", "k":
		s.sel, s.errText = max(s.sel-1, 0), ""
	case "down", "j":
		s.sel, s.errText = min(s.sel+1, len(settings)-1), ""
	case "enter", "space":
		if st.file != "" && !s.editing {
			if err := os.MkdirAll(s.dir, 0o700); err != nil {
				s.errText = err.Error()
				return s, nil
			}
			return s, openEditor([]string{filepath.Join(s.dir, st.file)}, func(err error) tea.Msg { return settingsFileEdited{err} })
		}
		f := field(&s.cfg, st.key)
		switch {
		case s.editing:
			return s.save(set(s.cfg, st, s.input.Value())), nil
		case f.Kind() == reflect.Bool:
			next := s.cfg
			field(&next, st.key).SetBool(!f.Bool())
			return s.save(next, next.Validate()), nil
		case st.choices != nil:
			i := slices.Index(st.choices, f.String())
			return s.save(set(s.cfg, st, st.choices[(i+1)%len(st.choices)])), nil
		}
		s.editing, s.errText = true, ""
		s.input.EchoMode = textinput.EchoNormal
		if st.key == "api_key" {
			s.input.EchoMode = textinput.EchoPassword // typed blind, never shown
		}
		s.input.SetWidth(max(s.width-valueColumn-1, 10))
		s.input.SetValue(edited(s.cfg, st))
		s.input.CursorEnd()
		return s, s.input.Focus()
	case "esc", "q":
		if s.editing {
			s.editing, s.errText = false, ""
			s.input.Blur()
			return s, nil
		}
		done := settingsDone{s.saved, s.cfg}
		return s, func() tea.Msg { return done }
	}
	return s, nil
}

func (s settingsModel) View() string {
	var b strings.Builder
	b.WriteString(userStyle.Render("blinker-agent settings") + dimStyle.Render(ansi.Truncate(" · "+s.dir, max(s.width-23, 10), "…")) + "\n")
	for i, st := range settings {
		if st.section != "" {
			b.WriteString("\n" + userStyle.Render(st.section) + "\n")
		}
		var value string
		if st.file != "" {
			value = s.files[st.file]
		} else {
			value = show(s.cfg, st)
		}
		line := ansi.Truncate(Sanitize(fmt.Sprintf("%-20s %s", st.label, value)), max(s.width-4, 20), "…")
		switch {
		case i == s.sel && s.editing:
			b.WriteString(selStyle.Render(fmt.Sprintf("› %-20s ", st.label)) + s.input.View() + "\n")
		default:
			b.WriteString(menuRow(line, i == s.sel))
		}
	}
	st := settings[s.sel]
	if st.file != "" {
		b.WriteString("\n" + wrap(Sanitize(st.help+" ("+filepath.Join(s.dir, st.file)+")"), s.width) + "\n")
	} else {
		_, file := lookup(&s.cfg, st.key)
		b.WriteString("\n" + wrap(st.help+" ("+st.key+" in "+file+")", s.width) + "\n")
	}
	if s.errText != "" {
		b.WriteString(errStyle.Render(wrap(Sanitize(s.errText), s.width)) + "\n")
	}
	switch {
	case st.file != "":
		b.WriteString(dimStyle.Render("↑/↓ choose · enter edits in $EDITOR · esc or ctrl+c closes"))
	case s.editing:
		b.WriteString(dimStyle.Render("enter saves · esc cancels · ctrl+c closes"))
	case field(&s.cfg, st.key).Kind() == reflect.Bool || st.choices != nil:
		b.WriteString(dimStyle.Render("↑/↓ choose · enter changes · esc or ctrl+c closes · changes apply the next time blinker starts"))
	default:
		b.WriteString(dimStyle.Render("↑/↓ choose · enter edits · esc or ctrl+c closes · changes apply the next time blinker starts"))
	}
	return b.String()
}