Vectrum
Sign In
527 lines · 18.2 KB · Go
package tui

import (
	"strings"

	"github.com/charmbracelet/bubbles/textarea"
	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/lipgloss"

	"codey/internal/config"
)

// The settings panel: /settings. One row per thing the config file holds,
// changed in place and applied as you move, which is what separates it from
// the setup wizard. The wizard is an interview — it asks every question once,
// in order, and is how the file comes to exist. This is the amendment: open
// it, change the one thing, leave.
//
// Everything it offers comes from the same option lists the wizard's steps are
// built from, so the two screens cannot come to disagree about what a theme is
// called or what an effort level means.

type settingRow int

const (
	setModelRow settingRow = iota
	setEffortRow
	setApprovalsRow
	setWebSearchRow
	setDiagnosticsRow
	setClaudeSkillsRow
	setThemeRow
	numSettingRows
)

var settingLabels = [numSettingRows]string{
	"Model", "Effort", "Approvals", "Web search", "Diagnostics", "Claude skills", "Theme",
}

// settingsHeadRows is how many rows sit above the list: the heading, which
// carries the keys on its right.
const settingsHeadRows = 1

// openSettings puts the panel up, on the row most likely to be wanted.
func (m *model) openSettings() tea.Cmd {
	m.phase = phaseSettings
	m.setIdx = setModelRow
	m.setTouched = [numSettingRows]bool{}
	m.setModelWas = m.modelInForce()
	m.setWeb = m.opts.Config.WebSearchMode()
	m.setClaude = m.opts.Config.ClaudeSkillsMode()
	m.setDiag = diagnosticsInForce(m.opts.Config)
	// A theme change repaints everything, including whatever was marked.
	m.clearSelection()
	m.closeMenu()
	return m.sync()
}

// onSettingsKey drives the panel. Like the other full-width pieces of chrome
// it answers every key while it is up: there is no prompt behind it to type
// into, so nothing falls through.
func (m *model) onSettingsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
	switch key := msg.String(); key {
	case "esc", "ctrl+c":
		return m, m.closeSettings()

	case "up", "k", "ctrl+p", "shift+tab":
		m.moveSettingRow(-1)

	case "down", "j", "ctrl+n", "tab":
		m.moveSettingRow(1)

	case "left", "h":
		m.cycleSetting(m.setIdx, -1)

	case "right", "l", " ":
		m.cycleSetting(m.setIdx, 1)

	case "enter":
		// The model row is the one setting whose choices this build does not
		// know: they come from whatever the endpoint serves. So enter hands
		// over to the picker that asks it, rather than cycling a list that
		// could not name the model a gateway is actually running.
		if m.setIdx == setModelRow {
			m.setFrom = true
			return m, m.openModelPicker()
		}
		m.cycleSetting(m.setIdx, 1)

	default:
		// A digit jumps to a row, the way it picks a model or an effort level
		// in the other two panels.
		if len(key) == 1 && key[0] >= '1' && key[0] <= '9' {
			if i := int(key[0] - '1'); i < int(numSettingRows) {
				m.setIdx = settingRow(i)
			}
		}
	}
	return m, m.sync()
}

// moveSettingRow walks the list. It stops at the ends rather than wrapping:
// the rows are a list to read, not a dial, and the values wrap already.
func (m *model) moveSettingRow(d int) {
	m.setIdx = settingRow(clampIdx(int(m.setIdx)+d, int(numSettingRows)))
}

// closeSettings puts the prompt back and saves, if there is anything to save.
//
// There is no cancel: every change has already been applied to the running
// session as it was made, which is the point of a panel you watch the theme
// change under. Leaving is therefore only ever about the file.
func (m *model) closeSettings() tea.Cmd {
	m.phase = phaseIdle
	m.input.Focus()
	themed := m.setTouched[setThemeRow]
	cfg := m.settingsConfig()
	m.setTouched = [numSettingRows]bool{}

	if cfg.Equal(m.opts.Config) {
		// Nothing was changed, or it was changed back. Leaving must not
		// rewrite the file, and it is not worth a line of scrollback either.
		return tea.Batch(m.sync(), textarea.Blink)
	}
	m.opts.Config = cfg

	// The setup marker is deliberately not stamped here. It means "the
	// questions were all asked and answered", and this panel does not ask
	// them: someone who has only ever changed their theme has not been
	// through setup, and should still be offered it.
	note := "settings saved: " + settingsSummary(cfg)
	if m.opts.SaveConfig != nil {
		if err := m.opts.SaveConfig(cfg); err != nil {
			// The settings are already in force; only the file failed.
			note = "settings: " + settingsSummary(cfg) +
				"\nthese are in force for this session, but saving them failed: " + err.Error()
		}
	}
	m.tr.addNotice(note)
	if themed {
		// Every entry was laid out in the old palette.
		m.refreshSplash()
	}
	return tea.Batch(m.sync(), textarea.Blink)
}

// settingsConfig is the config as the panel would save it: what was loaded,
// with the rows that were actually touched replaced by what is now in force.
//
// Only the touched rows, rather than every one, because the panel shows the
// session and the session is not always the file. -yolo turns accept-all on
// for one run; a panel that read it back off the policy would quietly write
// "run everything without asking" into the config of someone who asked for it
// once, on the command line, for one session.
func (m *model) settingsConfig() config.Config {
	cfg := m.opts.Config
	for r := settingRow(0); r < numSettingRows; r++ {
		if !m.settingTouched(r) {
			continue
		}
		v := m.settingValue(r)
		switch r {
		case setModelRow:
			cfg.Model = v
		case setEffortRow:
			cfg.Effort = v
		case setApprovalsRow:
			cfg.Approvals = v
		case setWebSearchRow:
			cfg.WebSearch = v
		case setDiagnosticsRow:
			cfg.Diagnostics = v
		case setClaudeSkillsRow:
			cfg.ClaudeSkills = v
		case setThemeRow:
			cfg.Theme = v
		}
	}
	return cfg
}

func settingsSummary(cfg config.Config) string {
	s := prettyModel(cfg.Model) + " · effort " + config.Or(cfg.Effort, defaultEffort) +
		" · approvals " + config.Or(cfg.Approvals, config.ApprovalsAsk) +
		" · " + config.Or(cfg.Theme, currentTheme.name) + " theme"
	// Web access is named only when there is some. "web search none" on every
	// save would be a line about a thing that is not happening.
	if mode := cfg.WebSearchMode(); mode != config.WebSearchNone {
		s += " · web search " + mode
	}
	// The other two are named the other way round, when they are off: both
	// default to a capability, so the line worth writing down is the one where
	// you have just taken one away.
	if !cfg.DiagnosticsEnabled() {
		s += " · no diagnostics"
	}
	if mode := cfg.ClaudeSkillsMode(); mode != config.ClaudeSkillsOff {
		s += " · claude skills " + mode
	}
	return s
}

// settingTouched reports whether a row was changed while the panel has been
// open, which is the only thing that gets written back.
func (m *model) settingTouched(r settingRow) bool {
	if r == setModelRow {
		// The model is changed through the picker the panel hands over to, so
		// there is no keystroke here to have recorded. What it was when the
		// panel opened is the whole test, and asking at this point rather than
		// as the picker closes keeps it out of the order those two do their
		// work in.
		return m.modelInForce() != m.setModelWas
	}
	return m.setTouched[r]
}

// settingValue is what a row is set to right now. It reads the session rather
// than the config, because the session is what the panel is showing: /effort,
// shift+tab and -yolo all change a setting without the file hearing about it,
// and a panel that disagreed with the status bar would be worse than useless.
func (m *model) settingValue(r settingRow) string {
	switch r {
	case setModelRow:
		return m.modelInForce()
	case setEffortRow:
		if m.opts.Effort == "" {
			return defaultEffort
		}
		return m.opts.Effort
	case setApprovalsRow:
		if m.opts.Policy != nil && m.opts.Policy.YOLO {
			return config.ApprovalsAcceptAll
		}
		return config.ApprovalsAsk
	case setWebSearchRow:
		return config.Or(m.setWeb, m.webSearchInForce())
	case setDiagnosticsRow:
		return config.Or(m.setDiag, diagnosticsInForce(m.opts.Config))
	case setClaudeSkillsRow:
		return config.Or(m.setClaude, m.opts.Config.ClaudeSkillsMode())
	case setThemeRow:
		return currentTheme.name
	}
	return ""
}

func (m *model) modelInForce() string {
	if m.opts.Client == nil {
		return ""
	}
	return m.opts.Client.Model()
}

// settingOptions is what a row cycles through. The model row has none: its
// choices come from the endpoint, through the picker.
func settingOptions(r settingRow) []wizOption {
	switch r {
	case setEffortRow:
		return effortOptions()
	case setApprovalsRow:
		return approvalOptions()
	case setWebSearchRow:
		return webSearchOptions()
	case setDiagnosticsRow:
		return diagnosticsOptions()
	case setClaudeSkillsRow:
		return claudeSkillsOptions()
	case setThemeRow:
		return themeOptions()
	}
	return nil
}

// cycleSetting moves a row to its next value and applies it there and then.
// It wraps: these are short lists, and a row that stops at one end reads as a
// row that has stopped working.
func (m *model) cycleSetting(r settingRow, d int) {
	opts := settingOptions(r)
	if len(opts) == 0 {
		return
	}
	i := (optionIndex(opts, m.settingValue(r)) + d + len(opts)) % len(opts)
	m.useSetting(r, opts[i].value)
}

// useSetting applies a value to the running session. It goes through the same
// setters /effort, shift+tab and the wizard use, so a setting changed here and
// the same setting changed from the prompt cannot come out different.
//
// The quiet setters are the ones used, not the announcing ones: the panel is
// on screen showing the value, and cycling past four themes should not cost
// four lines of transcript.
func (m *model) useSetting(r settingRow, v string) {
	switch r {
	case setEffortRow:
		m.useEffort(v)
	case setApprovalsRow:
		if m.opts.Policy != nil {
			m.opts.Policy.YOLO = v == config.ApprovalsAcceptAll
		}
	case setWebSearchRow:
		// Nothing to apply: the tools were built when the session started.
		// The value is remembered for the file and the detail line says when
		// it will actually mean something.
		m.setWeb = v
	case setDiagnosticsRow:
		// Same again: whether an edit is checked was decided when the edit
		// tool was built, so this row edits the file and says so.
		m.setDiag = v
	case setClaudeSkillsRow:
		// And again: which skills the model was told about went into the
		// first request of the session, which has already gone out.
		m.setClaude = v
	case setThemeRow:
		applyTheme(v)
	}
	m.setTouched[r] = true
}

// backFromPicker is where the model picker goes when it is done: back to the
// settings panel when that is what opened it, and to the prompt otherwise.
func (m *model) backFromPicker() phase {
	if !m.setFrom {
		return phaseIdle
	}
	m.setFrom = false
	return phaseSettings
}

// ---- view ---------------------------------------------------------------

// settingsView is the panel: a heading with the keys, a row per setting, a
// sentence about the row under the cursor, and where all of it is saved.
//
//	Settings                                    ↑↓ row · ←→ change · esc done
//	❯ Model         Claude Sonnet 5             $3 / $15 per Mtok
//	  Effort        high                        default
//	  Approvals     Ask me first                recommended
//	  Web search    No web access               default
//	  Diagnostics   On                          default
//	  Claude skills Leave them alone            default
//	  Theme         ember
//	  the model every turn is sent to · enter to pick from api.anthropic.com
//	  saved to ~/.config/codey/config.json
func (m *model) settingsView() string {
	width := maxInt(1, m.width)

	var b strings.Builder
	b.WriteString(spread(" "+styHeading.Render("Settings"),
		styFaint.Render("↑↓ row · ←→ change · esc done"), width))

	// Wide enough for the longest label and a gap after it, so the values line
	// up in a column of their own rather than against the widest word. Both
	// widths are measured rather than declared: a fixed twelve was right until
	// a setting called "Claude skills" arrived and ran straight into its own
	// value with no space between them.
	labelCol, valueCol := 0, 0
	values := make([]string, numSettingRows)
	for r := settingRow(0); r < numSettingRows; r++ {
		values[r] = m.settingLabel(r)
		labelCol = maxInt(labelCol, lipgloss.Width(settingLabels[r])+1)
		valueCol = maxInt(valueCol, lipgloss.Width(values[r]))
	}
	valueCol = minInt(valueCol, maxInt(10, width/3))

	for r := settingRow(0); r < numSettingRows; r++ {
		marker, style := "  ", styDim
		if r == m.setIdx {
			marker, style = styKey.Render("❯ "), styKey
		}
		line := marker + styFaint.Render(pad(settingLabels[r], labelCol)) +
			style.Render(pad(values[r], valueCol)) + "  " + styFaint.Render(m.settingNote(r))
		b.WriteString("\n" + truncateStyled(" "+line, width))
	}

	b.WriteString("\n" + truncateStyled("   "+styDim.Render(m.settingDetail(m.setIdx)), width))
	// Where it lands is on screen because "where did that go?" is the first
	// question a panel that saves by itself has to answer. It is the same
	// label the wizard signs off with.
	b.WriteString("\n" + truncateStyled("   "+styFaint.Render("saved to "+configPathLabel()), width))
	return b.String()
}

// settingLabel is how a row's value is spelled on screen: the option's own
// label where there is one, so the panel and the wizard say the same words.
func (m *model) settingLabel(r settingRow) string {
	v := m.settingValue(r)
	if r == setModelRow {
		return prettyModel(v)
	}
	for _, o := range settingOptions(r) {
		if o.value == v {
			return o.label
		}
	}
	return v
}

// settingNote is the short fact in the right-hand column — a price, which
// level the API would have picked anyway — kept to the width of a glance.
func (m *model) settingNote(r settingRow) string {
	if r == setModelRow {
		return priceLabel(m.settingValue(r))
	}
	v := m.settingValue(r)
	for _, o := range settingOptions(r) {
		if o.value == v {
			return o.note
		}
	}
	return ""
}

// settingDetail is the sentence about the row under the cursor. It is one line
// for the whole panel rather than one per row, so the list stays scannable —
// the same trade the wizard makes.
func (m *model) settingDetail(r settingRow) string {
	if r == setModelRow {
		detail := m.settingValue(r)
		if detail == "" {
			detail = "no model set"
		}
		if m.opts.Client != nil {
			detail += " · enter to pick from " + endpointLabel(m.opts.Client)
		}
		return detail
	}
	v := m.settingValue(r)
	for _, o := range settingOptions(r) {
		if o.value == v {
			desc := o.desc
			if r == setWebSearchRow {
				if v == config.WebSearchCustom {
					desc = m.opts.Config.SearchCommandLine()
				}
				// Against what is running, not against the file: the file can
				// already say "claude" while this process was started before
				// that was written and has no hosted tool in its requests.
				if v != m.webSearchInForce() {
					desc += " · takes effect next start"
				}
			}
			// The same test for the other two settings this session cannot
			// take on mid-conversation: the checkers are attached to the edit
			// tool when it is built, and which skills the model knows about
			// went out with the first request.
			if r == setDiagnosticsRow && v != m.diagnosticsInForce() {
				desc += " · takes effect next start"
			}
			if r == setClaudeSkillsRow && v != m.claudeSkillsInForce() {
				desc += " · takes effect next start"
			}
			return desc
		}
	}
	return ""
}

// webSearchInForce is the mode this session is actually running with. An
// unset Options.WebSearch means none, the same reading the config gives an
// absent key — a caller that never mentioned web search did not get any.
func (m *model) webSearchInForce() string {
	return config.Or(m.opts.WebSearch, config.WebSearchNone)
}

// diagnosticsOptions are the two answers to "should an edited file be handed
// to whatever on this machine can check it".
//
// It is the one option list with no step in the wizard. The interview is
// already long, and this is a setting whose default is right for nearly
// everyone: it costs a second on an edit, it runs a tool that is already
// installed, and nothing it reads leaves the machine — which is the test every
// other question here is really asking.
func diagnosticsOptions() []wizOption {
	return []wizOption{{
		value: config.DiagnosticsOn,
		label: "On",
		note:  "default",
		desc:  "a file the agent edits goes to the language server or linter installed for it, and what they say comes back in the tool's result",
	}, {
		value: config.DiagnosticsOff,
		label: "Off",
		desc:  "nothing is run after an edit; the agent finds out it broke the build when it next runs the tests, if it does",
	}}
}

// diagnosticsInForce is what this session is actually doing, falling back to
// what the config asks for. The session wins for the same reason it does for
// web search: a machine with no checker installed for anything in the tree is
// running without diagnostics however the file reads, and a panel that claimed
// otherwise would be wrong about the one thing it is there to report.
func (m *model) diagnosticsInForce() string {
	return config.Or(m.opts.Diagnostics, diagnosticsInForce(m.opts.Config))
}

// diagnosticsInForce reads a config's answer as one of the two words, so no
// caller has to spell the empty-means-on rule out again.
func diagnosticsInForce(cfg config.Config) string {
	if cfg.DiagnosticsEnabled() {
		return config.DiagnosticsOn
	}
	return config.DiagnosticsOff
}

// claudeSkillsInForce is the mode this session started under, falling back to
// the config for a caller that never said — a test, mostly, since the one
// caller that matters is told by main.
func (m *model) claudeSkillsInForce() string {
	return config.Or(m.opts.ClaudeSkillsMode, m.opts.Config.ClaudeSkillsMode())
}

// webSearchRestartNote is what both screens say when the web-search answer is
// one this process cannot take on. It names the restart because that is the
// whole of the fix, and there is no way to do it from inside the session: the
// tool set and the hosted tool definitions are built once, before the first
// request goes out.
func webSearchRestartNote(mode string) string {
	switch mode {
	case config.WebSearchNone:
		return "web search off — restart codey to drop the tools; this session keeps what it started with"
	case config.WebSearchClaude:
		return "web search: Claude's hosted tool — saved, but it reaches the API only from the next start of codey"
	case config.WebSearchCustom:
		return "web search: a command on this machine — saved, but the tools are registered only from the next start of codey"
	}
	return ""
}