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()
}
|