From 056500541fa8efc3c375eb6ddc93c9b7e3e5d59b Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 7 May 2026 17:23:43 +0200 Subject: [PATCH] feat(tui): /config opens interactive settings panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/tui/app.go | 145 +++++++++++++++++++++++++++++--------- internal/tui/rendering.go | 96 +++++++++++++++++++++++++ 2 files changed, 208 insertions(+), 33 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index 46dbdee..75fec78 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -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 + // /config set — 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 ") - 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 { diff --git a/internal/tui/rendering.go b/internal/tui/rendering.go index 2498f07..d471c40 100644 --- a/internal/tui/rendering.go +++ b/internal/tui/rendering.go @@ -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 {