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
setThemeRow
numSettingRows
)
var settingLabels = [numSettingRows]string{"Model", "Effort", "Approvals", "Web search", "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()
// 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 == 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 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
}
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 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 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 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
// 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.
const labelCol = 12
valueCol := 0
values := make([]string, numSettingRows)
for r := settingRow(0); r < numSettingRows; r++ {
values[r] = m.settingLabel(r)
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"
}
}
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)
}
// 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 ""
}