diff --git a/cmd/gnoma/main.go b/cmd/gnoma/main.go index a3c3ee3..c35b24c 100644 --- a/cmd/gnoma/main.go +++ b/cmd/gnoma/main.go @@ -359,8 +359,11 @@ func main() { } } - // Save QualityTracker data on exit (best-effort, suppressed in incognito) - defer func() { + // Save QualityTracker data on exit (best-effort, suppressed in + // incognito). Lifted to a named closure so the /profile switch path + // can fire it explicitly before syscall.Exec, since defers don't run + // after a successful exec. + saveQuality := func() { if *incognito { return } @@ -372,7 +375,8 @@ func main() { qualityPath := profile.QualityFile(gnomacfg.GlobalConfigDir()) _ = os.MkdirAll(filepath.Dir(qualityPath), 0o755) _ = os.WriteFile(qualityPath, data, 0o644) - }() + } + defer saveQuality() var armID router.ArmID if primaryProviderOK { armModel := *model @@ -958,6 +962,9 @@ func main() { modelUpdater = sess.SetModel modelMu.Unlock() + profileNames, _ := gnomacfg.ListProfiles() + var switchTarget string + m := tui.New(sess, tui.Config{ Firewall: fw, Engine: eng, @@ -974,12 +981,33 @@ func main() { Version: buildVersion, ModelUpdateCh: modelUpdateCh, SLM: slmInfo, + Profile: tui.ProfileInfo{Active: profile.Active, Name: profile.Name}, + ProfileNames: profileNames, + SwitchProfile: func(name string) { switchTarget = name }, }) p := tea.NewProgram(m) if _, err := p.Run(); err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) } + + // Re-exec for profile switching. syscall.Exec replaces this + // process image so defers do NOT run — fire the cleanups that + // matter (quality snapshot, SLM subprocess, session state) + // explicitly before handing off. + if switchTarget != "" { + saveQuality() + if slmCleanup != nil { + _ = slmCleanup() + } + if err := sess.Close(); err != nil { + logger.Warn("session close error before profile switch", "error", err) + } + if err := reExecForProfileSwitch(switchTarget); err != nil { + fmt.Fprintf(os.Stderr, "profile switch failed: %v\n", err) + os.Exit(1) + } + } } } diff --git a/cmd/gnoma/profile_cmd.go b/cmd/gnoma/profile_cmd.go index e9c7b81..7790c8c 100644 --- a/cmd/gnoma/profile_cmd.go +++ b/cmd/gnoma/profile_cmd.go @@ -8,6 +8,7 @@ import ( "path/filepath" "sort" "strings" + "syscall" "text/tabwriter" gnomacfg "somegit.dev/Owlibou/gnoma/internal/config" @@ -287,6 +288,71 @@ func formatProfileShow(w io.Writer, cfg *gnomacfg.Config, profile gnomacfg.Profi pf(w, "Session dir: %s\n", profile.SessionDir(projectRoot)) } +// reExecForProfileSwitch replaces the current process with a fresh +// gnoma running under --profile . Implemented as syscall.Exec +// rather than a child fork so we don't stack a new process level on +// every /profile switch, and so the child's exit code propagates to +// the user's shell without ambiguous "did the parent fail or the +// child?" error paths. +// +// The current process's defers will NOT run after syscall.Exec +// succeeds — call any required cleanup (quality.json snapshot, +// session.Close, etc.) before invoking this. On Unix targets only; +// gnoma's documented platforms are Linux and macOS. +// +// Belt-and-braces: profile names are validated upstream in the TUI, +// but we re-validate against the same `[A-Za-z0-9_-]+` rule the +// config layer enforces as defense against argv injection if some +// future caller skips validation. +func reExecForProfileSwitch(newProfile string) error { + if newProfile == "" { + return fmt.Errorf("empty profile name") + } + for _, r := range newProfile { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': + continue + default: + return fmt.Errorf("invalid profile name %q (refusing to exec)", newProfile) + } + } + + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("resolve self executable: %w", err) + } + newArgs := argsWithProfileReplaced(os.Args, newProfile) + // syscall.Exec wants argv[0] to be the program name. + argv := append([]string{exe}, newArgs...) + return syscall.Exec(exe, argv, os.Environ()) +} + +// argsWithProfileReplaced returns oldArgs[1:] with any prior --profile +// argument (in any of its forms: `--profile X`, `--profile=X`, +// `-profile X`, `-profile=X`) removed, plus `--profile ` +// appended. Other flags and positional args are preserved verbatim. +func argsWithProfileReplaced(oldArgs []string, newProfile string) []string { + out := make([]string, 0, len(oldArgs)) + skip := false + for i := 1; i < len(oldArgs); i++ { + if skip { + skip = false + continue + } + a := oldArgs[i] + switch { + case a == "--profile" || a == "-profile": + skip = true + continue + case strings.HasPrefix(a, "--profile="), strings.HasPrefix(a, "-profile="): + continue + } + out = append(out, a) + } + out = append(out, "--profile", newProfile) + return out +} + func sortedKeys(m map[string]string) string { keys := make([]string, 0, len(m)) for k := range m { diff --git a/cmd/gnoma/profile_cmd_test.go b/cmd/gnoma/profile_cmd_test.go index 64c572f..676012f 100644 --- a/cmd/gnoma/profile_cmd_test.go +++ b/cmd/gnoma/profile_cmd_test.go @@ -2,12 +2,88 @@ package main import ( "bytes" + "reflect" "strings" "testing" gnomacfg "somegit.dev/Owlibou/gnoma/internal/config" ) +func TestArgsWithProfileReplaced(t *testing.T) { + tests := []struct { + name string + in []string + next string + want []string + }{ + { + name: "no_prior_profile_flag", + in: []string{"gnoma", "--verbose"}, + next: "private", + want: []string{"--verbose", "--profile", "private"}, + }, + { + name: "replaces_double_dash_pair", + in: []string{"gnoma", "--profile", "work", "--verbose"}, + next: "private", + want: []string{"--verbose", "--profile", "private"}, + }, + { + name: "replaces_double_dash_equals", + in: []string{"gnoma", "--profile=work", "--max-turns", "50"}, + next: "private", + want: []string{"--max-turns", "50", "--profile", "private"}, + }, + { + name: "replaces_single_dash_pair", + in: []string{"gnoma", "-profile", "work"}, + next: "private", + want: []string{"--profile", "private"}, + }, + { + name: "replaces_single_dash_equals", + in: []string{"gnoma", "-profile=work"}, + next: "private", + want: []string{"--profile", "private"}, + }, + { + name: "preserves_positional_args", + in: []string{"gnoma", "providers"}, + next: "work", + want: []string{"providers", "--profile", "work"}, + }, + { + name: "preserves_mixed_flags_and_positional", + in: []string{"gnoma", "--verbose", "--profile", "old", "profile", "show", "work"}, + next: "new", + want: []string{"--verbose", "profile", "show", "work", "--profile", "new"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := argsWithProfileReplaced(tc.in, tc.next) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("argsWithProfileReplaced(%v, %q) = %v, want %v", tc.in, tc.next, got, tc.want) + } + }) + } +} + +func TestReExecForProfileSwitch_RejectsBadName(t *testing.T) { + // Belt-and-braces validation: bad names must be refused even if the + // caller skipped upstream validation. + cases := []string{"", "../foo", "foo bar", "foo;rm", "../../etc/passwd"} + for _, name := range cases { + t.Run(name, func(t *testing.T) { + err := reExecForProfileSwitch(name) + if err == nil { + t.Errorf("expected error for %q, got nil", name) + } + }) + } +} + func TestFormatProfileList_NoProfilesDir(t *testing.T) { var buf bytes.Buffer formatProfileList(&buf, nil, false, "", "", "/home/x/.config/gnoma/profiles", "/home/x/.config/gnoma/config.toml") diff --git a/docs/profiles.md b/docs/profiles.md index 3ec13c3..a5a3f21 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -187,6 +187,21 @@ Both `profile list` and `profile show` work even when profile resolution is otherwise broken — they're the recovery affordance for diagnosing misconfigurations. +## Inside the TUI + +The status bar carries a `· profile: ` indicator next to the SLM +badge so the active profile is always visible while you work. + +`/profile` (no args) prints the active profile and the list of +available ones. `/profile ` switches to another profile by +re-executing gnoma with `--profile ` — the implementation uses +`syscall.Exec` so no extra process level is stacked and the new +gnoma's exit code propagates directly to your shell. Conversation +history is not preserved across a switch; the new gnoma starts with +a fresh session. + +Autocomplete after `/profile ` offers configured profile names. + ## Merge semantics - **Scalars** (`provider.default`, `provider.model`, `tools.bash_timeout`, diff --git a/docs/superpowers/plans/2026-05-19-post-slm-unlock.md b/docs/superpowers/plans/2026-05-19-post-slm-unlock.md index 3270c0c..36e3079 100644 --- a/docs/superpowers/plans/2026-05-19-post-slm-unlock.md +++ b/docs/superpowers/plans/2026-05-19-post-slm-unlock.md @@ -242,13 +242,28 @@ C-2 (CLI surface) shipped 2026-05-19: Show never prints API key *values*, only the set of configured provider names. -C-3 (TUI integration, separate landing): +C-3 (TUI integration) shipped 2026-05-19: -- [ ] TUI `/profile` slash command (with autocomplete on profile - names, requires engine restart on switch). -- [ ] Status-bar indicator shows the active profile (dim, next to the +- [x] TUI `/profile` slash command (with autocomplete on profile + names, re-execs gnoma on switch — see note below on the engine-restart + approach). +- [x] Status-bar indicator shows the active profile (dim, next to the SLM badge: `· profile: work`). +**Engine restart approach (C-3 implementation note):** rather than +attempting in-process teardown and reinitialisation of the engine, +router, providers, and session store, `/profile ` calls +`syscall.Exec` to replace the current gnoma process with a fresh one +under `--profile `. Critical cleanups (quality.json snapshot, +SLM backend shutdown, session close) fire explicitly before exec +because defers don't run after a successful `syscall.Exec`. + +The trade-off: conversation history is not preserved across a switch. +This matches the plan's stated semantics — a profile change implies +different context, different keys, different permission mode — so +preserving chat state across the boundary would be confusing rather +than helpful. + ### Open design questions — resolved - **Profile selection persistence**: per-session only. Restart diff --git a/internal/tui/app.go b/internal/tui/app.go index c10ebba..171bad2 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -72,6 +72,17 @@ type Config struct { Version string // build version string (from ldflags) ModelUpdateCh <-chan struct{} // signals when the model name changes (discovery reconciliation) SLM SLMInfo // SLM backend status for the status bar + Profile ProfileInfo // active profile state for status bar + /profile command + ProfileNames []string // available profile names (sorted) for /profile autocomplete + SwitchProfile func(name string) // optional: when set, /profile calls this and returns tea.Quit +} + +// ProfileInfo mirrors the resolved profile state so the TUI can render +// the active profile in the status bar and answer `/profile`. Zero value +// (Active=false) renders nothing. +type ProfileInfo struct { + Active bool + Name string } // SLMInfo captures the resolved SLM backend state at startup so the TUI can @@ -843,7 +854,7 @@ Mark anything you're unsure about with TODO. Be terse — directive-style bullet m.suggestion = "" default: // Normal mode: prefix-based ghost text and dropdown - m.suggestion = matchCompletion(val, m.completionSrc) + m.suggestion = matchCompletion(val, m.completionSrc, m.config.ProfileNames) m.suggestions = matchSuggestions(val, m.completionSrc) } if len(m.suggestions) == 0 { @@ -1168,6 +1179,9 @@ func (m Model) handleCommand(cmd string) (tea.Model, tea.Cmd) { m.injectSystemContext(msg) return m, nil + case "/profile": + return m.handleProfileCommand(args) + case "/provider": if args != "" { m.messages = append(m.messages, chatMessage{role: "system", @@ -1308,7 +1322,7 @@ func (m Model) handleCommand(cmd string) (tea.Model, tea.Cmd) { case "/help": m.messages = append(m.messages, chatMessage{role: "system", - content: "Commands:\n /init generate or update AGENTS.md project docs\n /clear, /new clear chat and start new conversation\n /config show current config\n /incognito toggle incognito (Ctrl+X)\n /keys show keyboard shortcuts\n /model [name] list/switch models\n /permission [mode] set permission mode (Shift+Tab to cycle)\n /plugins list installed plugins\n /provider show current provider\n /replay scroll to top to re-read conversation\n /resume [id] list or restore saved sessions\n /shell [cmd] open interactive shell (or run cmd in shell)\n /skills list loaded skills\n /usage show token usage and cost\n /help show this help\n /quit exit gnoma\n\nSkills (use / [args] to invoke):\n Add .md files with YAML front matter to .gnoma/skills/ or ~/.config/gnoma/skills/"}) + content: "Commands:\n /init generate or update AGENTS.md project docs\n /clear, /new clear chat and start new conversation\n /config show current config\n /incognito toggle incognito (Ctrl+X)\n /keys show keyboard shortcuts\n /model [name] list/switch models\n /permission [mode] set permission mode (Shift+Tab to cycle)\n /plugins list installed plugins\n /profile [name] list profiles / switch (re-execs gnoma)\n /provider show current provider\n /replay scroll to top to re-read conversation\n /resume [id] list or restore saved sessions\n /shell [cmd] open interactive shell (or run cmd in shell)\n /skills list loaded skills\n /usage show token usage and cost\n /help show this help\n /quit exit gnoma\n\nSkills (use / [args] to invoke):\n Add .md files with YAML front matter to .gnoma/skills/ or ~/.config/gnoma/skills/"}) return m, nil case "/keys": @@ -1838,3 +1852,94 @@ func formatError(err error) string { } return msg } + +// handleProfileCommand implements the /profile slash command. +// +// `/profile` (no args) prints the active profile, the base config path, +// and the list of available profiles. When profile mode isn't engaged, +// it points the user at `gnoma profile list` for setup guidance. +// +// `/profile ` validates the name against the cached profile list +// and, when SwitchProfile is wired, signals main to re-exec gnoma with +// `--profile `. The TUI returns tea.Quit so the current process +// can tear down cleanly before the new gnoma takes over. We avoid +// in-process profile swapping because the engine, router, providers, +// and session store would all need coordinated reinitialisation — +// a re-exec is simpler and more correct. +func (m Model) handleProfileCommand(args string) (tea.Model, tea.Cmd) { + args = strings.TrimSpace(args) + + if args == "" { + m.messages = append(m.messages, chatMessage{role: "system", content: m.formatProfileSummary()}) + return m, nil + } + + // Profile mode must be engaged for switching to be meaningful. + if !m.config.Profile.Active { + m.messages = append(m.messages, chatMessage{role: "error", + content: "profile mode is not enabled — see `gnoma profile list` for setup guidance"}) + return m, nil + } + + name := args + if !contains(m.config.ProfileNames, name) { + avail := strings.Join(m.config.ProfileNames, ", ") + m.messages = append(m.messages, chatMessage{role: "error", + content: fmt.Sprintf("unknown profile %q — available: %s", name, avail)}) + return m, nil + } + + if name == m.config.Profile.Name { + m.messages = append(m.messages, chatMessage{role: "system", + content: fmt.Sprintf("already using profile %q", name)}) + return m, nil + } + + if m.config.SwitchProfile == nil { + m.messages = append(m.messages, chatMessage{role: "error", + content: "in-process profile switching is not wired in this build; restart with: gnoma --profile " + name}) + return m, nil + } + + m.messages = append(m.messages, chatMessage{role: "system", + content: fmt.Sprintf("switching to profile %q — re-executing gnoma...", name)}) + m.config.SwitchProfile(name) + return m, tea.Quit +} + +func (m Model) formatProfileSummary() string { + var b strings.Builder + if m.config.Profile.Active { + fmt.Fprintf(&b, "Active profile: %s\n", m.config.Profile.Name) + } else { + b.WriteString("Profile mode: not enabled (legacy single-config)\n") + b.WriteString("\nTo enable profiles, see `gnoma profile list` or docs/profiles.md.\n") + return b.String() + } + + if len(m.config.ProfileNames) == 0 { + b.WriteString("\n(no other profiles configured)\n") + return b.String() + } + + b.WriteString("\nAvailable profiles:\n") + for _, n := range m.config.ProfileNames { + marker := " " + if n == m.config.Profile.Name { + marker = "→ " + } + fmt.Fprintf(&b, "%s%s\n", marker, n) + } + b.WriteString("\nUsage: /profile — re-execs gnoma with the chosen profile.\n") + b.WriteString("Conversation history is not preserved across a switch.\n") + return b.String() +} + +func contains(haystack []string, needle string) bool { + for _, s := range haystack { + if s == needle { + return true + } + } + return false +} diff --git a/internal/tui/completions.go b/internal/tui/completions.go index df07ab3..33d0841 100644 --- a/internal/tui/completions.go +++ b/internal/tui/completions.go @@ -28,6 +28,7 @@ var builtinCommands = []cmdEntry{ {"/perm", "show or set permission mode"}, {"/permission", "show or set permission mode"}, {"/plugins", "list installed plugins"}, + {"/profile", "list profiles or switch to one (re-execs gnoma)"}, {"/provider", "list or switch provider"}, {"/quit", "quit gnoma"}, {"/replay", "replay last assistant response"}, @@ -79,13 +80,15 @@ func matchSuggestions(input string, commands []cmdEntry) []cmdEntry { } // matchCompletion returns the unique ghost-text completion, or "". -// Used for Tab acceptance of a single unambiguous match. -func matchCompletion(input string, commands []cmdEntry) string { +// Used for Tab acceptance of a single unambiguous match. profileNames +// is the dynamic completion source for `/profile ` — pass nil +// when none are known. +func matchCompletion(input string, commands []cmdEntry, profileNames []string) string { if !strings.HasPrefix(input, "/") || len(input) < 2 { return "" } if strings.Contains(input, " ") { - return matchArgCompletion(input) + return matchArgCompletion(input, profileNames) } suggestions := matchSuggestions(input, commands) if len(suggestions) == 1 && suggestions[0].name != input { @@ -123,7 +126,9 @@ func fuzzyMatchCommands(query string, commands []cmdEntry) []cmdEntry { } // matchArgCompletion handles second-level completion for commands with args. -func matchArgCompletion(input string) string { +// profileNames is the dynamic source for `/profile `; pass nil when +// profile mode isn't engaged. +func matchArgCompletion(input string, profileNames []string) string { parts := strings.SplitN(input, " ", 2) if len(parts) != 2 { return "" @@ -142,6 +147,16 @@ func matchArgCompletion(input string) string { return cmd + " " + mode } } + case "/profile": + if arg == "" || len(profileNames) == 0 { + return "" + } + lower := strings.ToLower(arg) + for _, name := range profileNames { + if strings.HasPrefix(strings.ToLower(name), lower) && name != arg { + return cmd + " " + name + } + } } return "" } diff --git a/internal/tui/completions_test.go b/internal/tui/completions_test.go index 6a9f2ed..085a459 100644 --- a/internal/tui/completions_test.go +++ b/internal/tui/completions_test.go @@ -34,7 +34,7 @@ func TestMatchCompletion(t *testing.T) { } for _, tt := range tests { - got := matchCompletion(tt.input, cmds) + got := matchCompletion(tt.input, cmds, nil) if got != tt.want { t.Errorf("matchCompletion(%q) = %q, want %q", tt.input, got, tt.want) } @@ -113,9 +113,49 @@ func TestMatchArgCompletion(t *testing.T) { } for _, tt := range tests { - got := matchArgCompletion(tt.input) + got := matchArgCompletion(tt.input, nil) if got != tt.want { t.Errorf("matchArgCompletion(%q) = %q, want %q", tt.input, got, tt.want) } } } + +func TestMatchArgCompletion_Profile(t *testing.T) { + profiles := []string{"experiment", "private", "work"} + tests := []struct { + input string + want string + }{ + {"/profile w", "/profile work"}, + {"/profile p", "/profile private"}, + {"/profile work", ""}, // already complete + {"/profile e", "/profile experiment"}, + {"/profile z", ""}, // no match + {"/profile ", ""}, // empty arg — wait for input + } + for _, tt := range tests { + got := matchArgCompletion(tt.input, profiles) + if got != tt.want { + t.Errorf("matchArgCompletion(%q, profiles) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestMatchCompletion_DispatchesToProfileArgCompletion(t *testing.T) { + // End-to-end: matchCompletion sees "/profile w", forwards to + // matchArgCompletion with profileNames, gets back "/profile work". + cmds := []cmdEntry{{"/profile", "profiles"}} + got := matchCompletion("/profile w", cmds, []string{"work", "private"}) + if got != "/profile work" { + t.Errorf("matchCompletion(/profile w) = %q, want /profile work", got) + } +} + +func TestMatchArgCompletion_ProfileNoNamesAvailable(t *testing.T) { + // When profile mode isn't engaged, profileNames is nil/empty and the + // completer must not try to suggest anything. + got := matchArgCompletion("/profile w", nil) + if got != "" { + t.Errorf("matchArgCompletion(profile, nil) = %q, want empty", got) + } +} diff --git a/internal/tui/rendering.go b/internal/tui/rendering.go index fbdd86e..669871a 100644 --- a/internal/tui/rendering.go +++ b/internal/tui/rendering.go @@ -534,7 +534,7 @@ func (m Model) renderStatus() string { if !status.ToolsAvailable { provModel += " " + sStatusDim.Render("text-only") } - left := sStatusHighlight.Render(provModel) + renderSLMBadge(m.config.SLM) + left := sStatusHighlight.Render(provModel) + renderSLMBadge(m.config.SLM) + renderProfileBadge(m.config.Profile) // Center: cwd + git branch dir := filepath.Base(m.cwd) @@ -631,6 +631,17 @@ func renderSLMBadge(info SLMInfo) string { return sStatusDim.Render(label) } +// renderProfileBadge produces " · profile: " for the status bar's +// left side. Returns "" when profile mode is not engaged so legacy +// single-config installations don't carry a "profile: default" badge +// that adds noise without information. +func renderProfileBadge(info ProfileInfo) string { + if !info.Active || info.Name == "" { + return "" + } + return sStatusDim.Render(" · profile: " + info.Name) +} + // formatTurnUsage produces a compact token summary for a single turn. func formatTurnUsage(u message.Usage) string { parts := []string{fmt.Sprintf("in: %d", u.InputTokens), fmt.Sprintf("out: %d", u.OutputTokens)} diff --git a/internal/tui/statusbar_test.go b/internal/tui/statusbar_test.go index 65dacd8..b6e2a2c 100644 --- a/internal/tui/statusbar_test.go +++ b/internal/tui/statusbar_test.go @@ -51,6 +51,29 @@ func TestRenderContextBar_Warning(t *testing.T) { } } +func TestRenderProfileBadge_Legacy(t *testing.T) { + got := renderProfileBadge(ProfileInfo{}) + if got != "" { + t.Errorf("inactive profile should render nothing, got %q", got) + } +} + +func TestRenderProfileBadge_Active(t *testing.T) { + got := renderProfileBadge(ProfileInfo{Active: true, Name: "work"}) + if !strings.Contains(got, "profile: work") { + t.Errorf("active badge should show 'profile: work', got %q", got) + } +} + +func TestRenderProfileBadge_ActiveButNameEmpty(t *testing.T) { + // Defensive: never render a badge when Name happens to be empty + // even if Active was somehow set true. + got := renderProfileBadge(ProfileInfo{Active: true}) + if got != "" { + t.Errorf("active+empty name should render nothing, got %q", got) + } +} + func TestFormatTurnUsage_Basic(t *testing.T) { u := message.Usage{InputTokens: 1500, OutputTokens: 200} got := formatTurnUsage(u)