package agent import ( "context" "encoding/json" "fmt" "os" "path/filepath" "slices" "strconv" "strings" ) func (a *agent) handleCommand(ctx context.Context, input string) bool { if !strings.HasPrefix(input, "/") { return false } parts := strings.Fields(input) if len(parts) == 0 { return false } cmd := parts[0] args := parts[1:] listFromHandler := func(name string) { if h := a.listHandlers[name]; h != nil { for _, item := range h() { a.emit(infoEvent(" " + item)) } } } type cmdFn func([]string) cmds := map[string]cmdFn{ "/i": func(args []string) { prompt := strings.Join(args, " ") if prompt == "" { a.emit(infoEvent("error: /i requires a prompt")) return } if a.IsRunning() { a.Inject(prompt) } else { go a.Submit(context.Background(), prompt) } }, "/irw": func(args []string) { prompt := strings.Join(args, " ") if prompt == "" { a.emit(infoEvent("error: /irw requires a prompt")) return } a.injectRewrite(prompt) }, "/backend": func(args []string) { if len(args) == 0 { a.emit(infoEvent(a.r.runtime.Backend.Name())) return } if a.IsRunning() { a.emit(infoEvent("error: cannot switch backend while agent is running")) return } be, err := a.r.newBackend(args[0]) if err != nil { a.emit(infoEvent(fmt.Sprintf("error: failed to switch backend: %v", err))) return } a.r.runtime.Backend = be a.emit(infoEvent(fmt.Sprintf("switched backend to: %s (model: %s)", be.Name(), be.Model()))) }, "/models": func(args []string) { models := a.r.runtime.Backend.Models(ctx) if len(models) == 0 { a.emit(infoEvent("no models available")) return } slices.Sort(models) current := a.r.runtime.Backend.Model() for _, m := range models { marker := " " if m == current { marker = "* " } a.emit(infoEvent(marker + m)) } }, "/model": func(args []string) { if len(args) == 0 { a.emit(infoEvent(a.r.runtime.Backend.Model())) return } a.r.runtime.Backend.SetModel(args[0]) a.emit(infoEvent("switched model to: " + args[0])) }, "/maxsteps": func(args []string) { if len(args) == 0 { if a.r.runtime.MaxSteps == 0 { a.emit(infoEvent("maxsteps: unlimited")) } else { a.emit(infoEvent(fmt.Sprintf("maxsteps: %d", a.r.runtime.MaxSteps))) } return } n, err := strconv.Atoi(args[0]) if err != nil || n < 0 { a.emit(infoEvent("error: maxsteps requires a non-negative integer (0 = unlimited)")) return } a.r.runtime.MaxSteps = n if n == 0 { a.emit(infoEvent("maxsteps: unlimited")) } else { a.emit(infoEvent(fmt.Sprintf("maxsteps: %d", n))) } }, "/agents": func(args []string) { seen := make(map[string]bool) found := false for _, dir := range AgentsDirs() { entries, err := os.ReadDir(dir) if err != nil { continue } for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { continue } name := strings.TrimSuffix(e.Name(), ".json") if seen[name] { continue } seen[name] = true marker := " " if name == a.r.agentName { marker = "* " } a.emit(infoEvent(marker + name)) found = true } } if !found { a.emit(infoEvent("no agents found")) } }, "/agent": func(args []string) { if len(args) == 0 { a.emit(infoEvent("active agent: " + a.r.agentName)) return } if a.IsRunning() { a.emit(infoEvent("error: cannot switch agent while agent is running")) return } name := args[0] cfgPath := AgentConfigPath(a.r.agentsDir, name) f, err := os.Open(cfgPath) if err != nil { a.emit(infoEvent(fmt.Sprintf("error: agent %q: %v", name, err))) return } cfg, err := Load(f) f.Close() if err != nil { a.emit(infoEvent(fmt.Sprintf("error: agent %q: %v", name, err))) return } d := a.r.newDispatcher() env := []string{"OLLIE_SESSION_ID=" + a.sess.ID(), "OLLIE_UNAME=" + a.sess.Uname()} env = append(env, a.r.promptEnvExtra...) rt := BuildRuntime(cfg, d, a.sess.CWD(), env, a.r.baseLayers...) if rt.CfgBackend != "" { newBe, err := a.r.newBackend(rt.CfgBackend) if err != nil { a.emit(infoEvent(fmt.Sprintf("error: backend %q: %v", rt.CfgBackend, err))) return } if rt.CfgModel != "" { newBe.SetModel(rt.CfgModel) } rt.Backend = newBe } else { rt.Backend = a.r.runtime.Backend if rt.CfgModel != "" { rt.Backend.SetModel(rt.CfgModel) } } a.r.runtime = rt a.r.agentName = name a.r.history = nil a.pushSessionEnv() a.notifyChange() for _, msg := range rt.Messages { a.emit(infoEvent(msg)) } a.emit(infoEvent("agent: " + name)) }, "/compact": func(args []string) { if a.IsRunning() { a.emit(infoEvent("error: cannot compact while agent is running")) return } if a.r.history == nil { a.emit(infoEvent("nothing to compact")) return } snapshot := a.r.history.PreCompactionSnapshot() a.setState("compacting") n, err := a.runCompact(ctx, "manual") a.setState("idle") if err != nil { a.emit(infoEvent("compact error: " + err.Error())) return } if n == 0 { a.emit(infoEvent("nothing to compact")) return } if a.sessionsDir != "" && a.sess.ID() != "" { histPath := a.activeSessionPath(a.sess.ID(), ".compaction.jsonl") if err := os.MkdirAll(filepath.Dir(histPath), 0700); err != nil { a.emit(infoEvent("compaction history save: " + err.Error())) } else if data, err := json.Marshal(snapshot); err == nil { f, err := os.OpenFile(histPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) if err == nil { f.Write(append(data, '\n')) //nolint:errcheck f.Close() //nolint:errcheck } } } a.emit(infoEvent(fmt.Sprintf("compacted %d messages", n))) a.saveSession() }, "/context": func(args []string) { if a.r.history == nil { a.emit(infoEvent("no active session")) return } ctxLen := a.r.runtime.Backend.ContextLength(ctx) if ctxLen <= 0 { ctxLen = defaultContextLength } estimated := a.r.history.estimateTokens() pct := estimated * 100 / ctxLen a.emit(infoEvent(fmt.Sprintf("~%d / %d tokens (%d%%)", estimated, ctxLen, pct))) a.emit(infoEvent(strings.TrimRight(a.r.history.contextDebug(), "\n"))) }, "/cost": func(args []string) { if a.r.history == nil { a.emit(infoEvent("no active session")) return } a.emit(infoEvent(fmt.Sprintf("last=$%.4f session=$%.4f", a.r.history.LastTurnCostUSD, a.r.history.SessionCostUSD))) }, "/usage": func(args []string) { if a.r.history == nil { a.emit(infoEvent("no active session")) return } ctxLen := a.r.runtime.Backend.ContextLength(ctx) if ctxLen <= 0 { ctxLen = defaultContextLength } estimated := a.r.history.estimateTokens() pct := estimated * 100 / ctxLen usageStr := fmt.Sprintf("~%d / %d tokens (%d%%) | %d in, %d out, %d requests", estimated, ctxLen, pct, a.r.history.TotalInputTokens, a.r.history.TotalOutputTokens, a.r.history.TotalRequests) if a.r.history.Estimated { usageStr += " [estimated]" } a.emit(infoEvent(usageStr)) }, "/history": func(args []string) { if a.r.history == nil { a.emit(infoEvent("no active session")) return } for _, msg := range a.r.history.history() { preview := msg.Content if len(preview) > 200 { preview = preview[:200] + "..." } a.emit(infoEvent(fmt.Sprintf("[%s] %s", msg.Role, preview))) } }, "/clear": func(args []string) { if a.IsRunning() { a.emit(infoEvent("error: cannot clear while agent is running")) return } a.r.history = nil a.emit(infoEvent("cleared")) }, "/sessions": func(args []string) { allFlag := len(args) > 0 && args[0] == "-a" type sessionFile struct { id string path string } var files []sessionFile appendSessionFiles := func(dir string) { entries, err := os.ReadDir(dir) if err != nil { return } for i := len(entries) - 1; i >= 0; i-- { e := entries[i] if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { continue } files = append(files, sessionFile{ id: strings.TrimSuffix(e.Name(), ".json"), path: filepath.Join(dir, e.Name()), }) } } appendSessionFiles(a.sessionsDir) appendSessionFiles(filepath.Join(a.sessionsDir, "active")) cwd := a.CWD() found := false for _, file := range files { data, readErr := os.ReadFile(file.path) if readErr != nil { continue } var ps PersistedSession if json.Unmarshal(data, &ps) != nil { continue } if !allFlag && ps.CWD != "" && ps.CWD != cwd { continue } marker := " " if file.id == a.sess.ID() { marker = "* " } goal := "" for _, msg := range ps.Messages { if msg.Role == "user" { goal = msg.Content break } } if len(goal) > 60 { goal = goal[:60] + "..." } a.emit(infoEvent(marker + fmt.Sprintf("%-24s [%s] %q", file.id, ps.Agent, goal))) found = true } if !found { a.emit(infoEvent("no sessions for " + cwd)) } }, "/save": func(args []string) { if a.r.history == nil { a.emit(infoEvent("error: no active session")) return } if len(args) == 0 { a.emit(infoEvent("error: /save requires a name")) return } name := args[0] path := a.sessionsDir + "/" + name + ".json" if err := a.r.history.saveTo(path, name, a.r.agentName, a.CWD()); err != nil { a.emit(infoEvent("error: " + err.Error())) return } a.emit(infoEvent("saved: " + path)) }, "/resume": func(args []string) { if len(args) == 0 { a.emit(infoEvent("error: /resume requires a session id or name")) return } if a.IsRunning() { a.emit(infoEvent("error: cannot resume while agent is running")) return } name := args[0] path := a.sessionsDir + "/" + name + ".json" data, err := os.ReadFile(path) if err != nil { a.emit(infoEvent(fmt.Sprintf("error: %v", err))) return } var ps PersistedSession if err := json.Unmarshal(data, &ps); err != nil { a.emit(infoEvent(fmt.Sprintf("error: %v", err))) return } a.r.history = RestoreHistory(&ps) a.emit(infoEvent(fmt.Sprintf("resumed session %s (%d messages)", name, len(ps.Messages)))) }, "/cwd": func(args []string) { if len(args) == 0 { a.emit(infoEvent("cwd: " + a.CWD())) return } dir := strings.Join(args, " ") if err := a.SetCWD(dir); err != nil { a.emit(infoEvent("error: " + err.Error())) return } a.emit(infoEvent("cwd: " + dir)) }, "/skills": func(args []string) { listFromHandler("skills") }, "/tools": func(args []string) { listFromHandler("tools") }, "/sp": func(args []string) { a.emit(infoEvent(a.r.runtime.Preamble)) }, "/help": func(args []string) { lines := []string{ "Available commands:", " /agents - list available agent configs", " /sessions [-a] - list saved sessions (current cwd; -a for all)", " /save - save session as a named checkpoint", " /resume - resume a saved session", " /agent [name] - show or switch active agent", " /backend [type] - show current backend, or switch to ", " /model [name] - show current model, or switch to ", " /models - list available models", " /maxsteps [n] - show or set max tool-call steps (0 = unlimited)", " /skills - list available skills", " /tools - list available tools", " /cwd [path] - show or change working directory", " /i - inject prompt into the running turn", " /irw - rewrite the pending inject", " /queued [pop|clear] - manage queued prompts", " /compact - summarize conversation and compact context", " /context - show context size and message breakdown", " /cost - show last turn and session cost", " /usage - show token usage and context percentage", " /history - dump bounded message history", " /clear - clear session", " /kill - kill session", " /rn - rename session", " /sp - show rendered system prompt", " /help - show this help", " ! - run shell command", } for _, l := range lines { a.emit(infoEvent(l)) } }, } fn, ok := cmds[cmd] if !ok { return false } a.emit(infoEvent("")) fn(args) return true }