package tui import ( "strings" "github.com/charmbracelet/bubbles/textarea" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/johannSo/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 setClaudeCodeAuthRow setClaudeSkillsRow setThemeRow numSettingRows ) var settingLabels = [numSettingRows]string{ "Model", "Effort", "Approvals", "Web search", "Diagnostics", "Claude Code login", "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.setCCAuth = claudeCodeAuthMode(m.opts.Config) 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 setClaudeCodeAuthRow: cfg.ClaudeCodeAuth = 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 } // Named only when on, like web search: it is the exception, and the one // worth being reminded of. if cfg.ClaudeCodeAuthEnabled() { s += " · claude code login" } 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 setClaudeCodeAuthRow: return config.Or(m.setCCAuth, claudeCodeAuthMode(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 setClaudeCodeAuthRow: return claudeCodeAuthOptions() 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 setClaudeCodeAuthRow: // And again: the client, credential and all, was built before the // first frame was drawn. m.setCCAuth = 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" } if r == setClaudeCodeAuthRow && v != m.claudeCodeAuthInForce() { 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()) } // claudeCodeAuthInForce is what this session is running on, falling back to // the config where main did not say — which it does not when the question // never arose, because a key was given or the endpoint is not Anthropic's. // There the setting changes nothing on the next start either, and a panel // promising otherwise would be wrong. func (m *model) claudeCodeAuthInForce() string { return config.Or(m.opts.ClaudeCodeAuth, claudeCodeAuthMode(m.opts.Config)) } // claudeCodeAuthRestartNote is what the wizard says when its answer about the // Claude Code login is not the one this session started under. func claudeCodeAuthRestartNote(mode string) string { if mode == config.ClaudeCodeAuthOn { return "Claude Code login: on — saved; it is borrowed from the next start of codey, when no API key is set" } return "Claude Code login: off — saved; from the next start codey needs ANTHROPIC_API_KEY or a gateway token" } // 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 "" }