feat(tui): /profile slash command + status-bar profile badge (Phase C-3)

Adds the in-TUI surface for the profile system:

- Status bar carries " · profile: <name>" next to the SLM badge when
  profile mode is engaged (renders nothing in legacy single-config
  installations).
- /profile (no args) shows the active profile and lists available ones.
- /profile <name> switches by re-executing gnoma via syscall.Exec under
  --profile <name>. Critical cleanups (quality.json snapshot, SLM
  backend Close, session.Close) fire explicitly before exec since
  defers don't run after exec replaces the process image. Using
  syscall.Exec rather than a child process avoids stacking a process
  level on every switch and propagates the new gnoma's exit code
  directly to the shell.
- Autocomplete after "/profile " offers configured profile names; the
  completion source is threaded from main.go via tui.Config.

Conversation history is not preserved across a switch — profile change
implies different context, different keys, different permission mode,
so a clean reset is the correct semantic.
This commit is contained in:
2026-05-19 21:59:11 +02:00
parent 0d4defd637
commit a930dd34c4
10 changed files with 410 additions and 16 deletions
+31 -3
View File
@@ -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)
}
}
}
}
+66
View File
@@ -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 <name>. 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 <newProfile>`
// 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 {
+76
View File
@@ -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")
+15
View File
@@ -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: <name>` 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 <name>` switches to another profile by
re-executing gnoma with `--profile <name>` — 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`,
@@ -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 <name>` calls
`syscall.Exec` to replace the current gnoma process with a fresh one
under `--profile <name>`. 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
+107 -2
View File
@@ -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 <name> 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 /<name> [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 /<name> [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 <name>` validates the name against the cached profile list
// and, when SwitchProfile is wired, signals main to re-exec gnoma with
// `--profile <name>`. 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 <name> — 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
}
+19 -4
View File
@@ -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 <name>` — 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 <name>`; 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 ""
}
+42 -2
View File
@@ -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)
}
}
+12 -1
View File
@@ -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: <name>" 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)}
+23
View File
@@ -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)