package tui import ( "strings" "github.com/charmbracelet/bubbles/textarea" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "harness/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 setThemeRow numSettingRows ) var settingLabels = [numSettingRows]string{"Model", "Effort", "Approvals", "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() // 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 all four, 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 setThemeRow: cfg.Theme = v } } return cfg } func settingsSummary(cfg config.Config) string { return prettyModel(cfg.Model) + " · effort " + config.Or(cfg.Effort, defaultEffort) + " · approvals " + config.Or(cfg.Approvals, config.ApprovalsAsk) + " · " + config.Or(cfg.Theme, currentTheme.name) + " theme" } // 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 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 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 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/harness/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 { return o.desc } } return "" }