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:
+31
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user