feat(tui): /config opens interactive settings panel

Replaces the text dump with a navigable bordered overlay.
↑↓ to move, Enter to cycle/toggle values, Esc to close.
Shows: Model (cycles through discovered arms), Permission mode,
Incognito toggle.
This commit is contained in:
2026-05-07 17:23:43 +02:00
parent d3fdfe30b9
commit 056500541f
2 changed files with 208 additions and 33 deletions
+112 -33
View File
@@ -114,6 +114,10 @@ type Model struct {
permToolName string // which tool is asking
permArgs json.RawMessage // tool args for display
// Settings panel (/config)
configPanelOpen bool
configSelected int
// Session resume picker
resumePending bool
resumeSessions []session.Metadata
@@ -319,6 +323,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil // ignore other keys while prompting
}
// --- Settings panel (when /config is open) ---
if m.configPanelOpen {
const numSettings = 3
switch msg.String() {
case "up", "k":
if m.configSelected > 0 {
m.configSelected--
}
case "down", "j":
if m.configSelected < numSettings-1 {
m.configSelected++
}
case "enter":
m = m.applyConfigSetting()
case "esc", "ctrl+c", "q":
m.configPanelOpen = false
}
return m, nil
}
// --- Session picker (only when resume picker is open) ---
if m.resumePending {
switch msg.String() {
@@ -860,7 +884,7 @@ func (m Model) handleCommand(cmd string) (tea.Model, tea.Cmd) {
return m, nil
case "/config":
// /config set <key> <value>
// /config set <key> <value> — direct write, no panel
if strings.HasPrefix(args, "set ") {
parts := strings.SplitN(strings.TrimPrefix(args, "set "), " ", 2)
if len(parts) != 2 {
@@ -876,38 +900,9 @@ func (m Model) handleCommand(cmd string) (tea.Model, tea.Cmd) {
}
return m, nil
}
status := m.session.Status()
var b strings.Builder
b.WriteString("Current configuration:\n")
fmt.Fprintf(&b, " provider: %s\n", status.Provider)
fmt.Fprintf(&b, " model: %s\n", status.Model)
if m.config.Permissions != nil {
fmt.Fprintf(&b, " permission: %s\n", m.config.Permissions.Mode())
}
fmt.Fprintf(&b, " incognito: %v\n", m.incognito)
fmt.Fprintf(&b, " cwd: %s\n", m.cwd)
if m.gitBranch != "" {
fmt.Fprintf(&b, " git branch: %s\n", m.gitBranch)
}
if m.config.SLMManager != nil {
slmStat := m.config.SLMManager.Status()
switch slmStat {
case slm.StatusReady:
url := m.config.SLMManager.BaseURL()
if url != "" {
fmt.Fprintf(&b, " slm: ready (running at %s)\n", url)
} else {
b.WriteString(" slm: ready (not started)\n")
}
case slm.StatusMissing:
b.WriteString(" slm: file missing — run: gnoma slm setup\n")
default:
b.WriteString(" slm: not set up — run: gnoma slm setup\n")
}
}
b.WriteString("\nConfig files: ~/.config/gnoma/config.toml, .gnoma/config.toml")
b.WriteString("\nEdit: /config set <key> <value>")
m.messages = append(m.messages, chatMessage{role: "system", content: b.String()})
// No args — open interactive settings panel
m.configPanelOpen = true
m.configSelected = 0
return m, nil
case "/elf", "/elfs":
@@ -1502,6 +1497,90 @@ func diffPreviewWrite(content string) string {
return strings.TrimRight(b.String(), "\n")
}
// applyConfigSetting applies the Enter action for the currently selected settings item.
func (m Model) applyConfigSetting() Model {
switch m.configSelected {
case 0: // Model — cycle through arms
if m.config.Router == nil {
return m
}
arms := configPanelArms(m.config.Router.Arms())
if len(arms) == 0 {
return m
}
current := m.config.Router.ForcedArm()
idx := 0
for i, a := range arms {
if a.ID == current {
idx = (i + 1) % len(arms)
break
}
}
next := arms[idx]
m.config.Router.ForceArm(next.ID)
if m.config.Engine != nil {
m.config.Engine.SetModel(next.ModelName)
}
if ls, ok := m.session.(*session.Local); ok {
ls.SetModel(next.ModelName)
}
case 1: // Permission — cycle modes
if m.config.Permissions == nil {
return m
}
modes := []permission.Mode{
permission.ModeAuto,
permission.ModeDefault,
permission.ModeAcceptEdits,
permission.ModePlan,
permission.ModeBypass,
permission.ModeDeny,
}
cur := m.config.Permissions.Mode()
next := modes[0]
for i, md := range modes {
if md == cur {
next = modes[(i+1)%len(modes)]
break
}
}
m.config.Permissions.SetMode(next)
case 2: // Incognito — toggle
if m.config.Firewall != nil {
m.incognito = m.config.Firewall.Incognito().Toggle()
if m.config.Router != nil {
m.config.Router.SetLocalOnly(m.incognito)
}
}
}
return m
}
// configPanelArms returns a stable-ordered slice of arms suitable for cycling.
// Order: CLI agents first, then local models, then API arms, excluding stub/SLM.
func configPanelArms(arms []*router.Arm) []*router.Arm {
var cli, local, api []*router.Arm
for _, a := range arms {
if string(a.ID) == "slm/llamafile" {
continue // SLM is not a user-selectable primary arm
}
if a.IsCLIAgent {
cli = append(cli, a)
} else if a.IsLocal {
local = append(local, a)
} else {
api = append(api, a)
}
}
result := make([]*router.Arm, 0, len(cli)+len(local)+len(api))
result = append(result, cli...)
result = append(result, local...)
result = append(result, api...)
return result
}
// shellExe returns the path of the user's preferred interactive shell.
// Priority: $SHELL (Unix) / %COMSPEC% (Windows), then platform default.
func shellExe() string {
+96
View File
@@ -103,6 +103,11 @@ func (m Model) renderChat(height int) string {
lines = append(lines, "")
}
// Settings panel (/config)
if m.configPanelOpen {
lines = append(lines, m.renderConfigPanel(m.width)...)
}
// Transient: session resume picker
if m.resumePending && len(m.resumeSessions) > 0 {
lines = append(lines, "")
@@ -613,6 +618,97 @@ func formatTurnUsage(u message.Usage) string {
return strings.Join(parts, " · ")
}
// renderConfigPanel renders the interactive /config settings overlay.
func (m Model) renderConfigPanel(width int) []string {
rtr := m.config.Router
perm := m.config.Permissions
// Build the three setting rows
type row struct{ label, value string }
rows := make([]row, 3)
// Row 0: Model
modelVal := "none"
if rtr != nil {
forced := rtr.ForcedArm()
if forced != "" {
modelVal = string(forced)
} else {
arms := configPanelArms(rtr.Arms())
if len(arms) > 0 {
modelVal = string(arms[0].ID) + " (press Enter to select)"
} else {
modelVal = "none discovered"
}
}
}
rows[0] = row{"Model", modelVal}
// Row 1: Permission
permVal := "—"
if perm != nil {
permVal = string(perm.Mode())
}
rows[1] = row{"Permission", permVal}
// Row 2: Incognito
incogVal := "Off"
if m.incognito {
incogVal = "On"
}
rows[2] = row{"Incognito", incogVal}
// Measure widest label for alignment
maxLabel := 0
for _, r := range rows {
if len(r.label) > maxLabel {
maxLabel = len(r.label)
}
}
sSelected := lipgloss.NewStyle().
Background(cTeal).
Foreground(cMantle).
Bold(true)
sItem := lipgloss.NewStyle().Foreground(cText)
sLabel := lipgloss.NewStyle().Foreground(cSubtext)
innerW := width - 8 // border(2) + padding(2) each side = 8
if innerW < 30 {
innerW = 30
}
var bodyLines []string
for i, r := range rows {
labelPad := strings.Repeat(" ", maxLabel-len(r.label))
if i == m.configSelected {
line := fmt.Sprintf("%s%s: %s", r.label, labelPad, r.value)
// Pad to full inner width so the highlight fills the row
if lipgloss.Width(line) < innerW {
line += strings.Repeat(" ", innerW-lipgloss.Width(line))
}
bodyLines = append(bodyLines, sSelected.Render(line))
} else {
bodyLines = append(bodyLines, sLabel.Render(r.label+labelPad+": ")+sItem.Render(r.value))
}
}
bodyLines = append(bodyLines, "")
bodyLines = append(bodyLines, sHint.Render("↑↓ Navigate Enter Select/Toggle Esc Exit"))
body := strings.Join(bodyLines, "\n")
titleStyle := lipgloss.NewStyle().Foreground(cTeal).Bold(true)
boxStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(cTeal).
Padding(0, 1).
Width(innerW + 2) // +2 for padding
box := boxStyle.Render(titleStyle.Render("Settings") + "\n\n" + body)
return []string{"", box, ""}
}
// wrapText word-wraps text at word boundaries, preserving existing newlines.
// Uses ANSI-aware wrapping so lipgloss-styled text is measured correctly.
func wrapText(text string, width int) string {