···587587tool_repeat_limit: 3
588588# Overall run timeout.
589589timeout: "10m"
590590+591591+# Chat display settings.
592592+chat:
593593+ # Compact mode: omit user/assistant name prefixes in prompts and output.
594594+ # When true, shows a green bullet (•) for user prompt and plain text for assistant.
595595+ # Default: false (chat-style display with colored name prefixes).
596596+ compact_mode: false
597597+590598# Base directory for local state (memory/skills/heartbeat).
591599file_state_dir: "~/.morph"
592600# Prompt profile context injection.
+113
cmd/mistermorph/chatcmd/agents.go
···11+package chatcmd
22+33+import (
44+ "context"
55+ "errors"
66+ "fmt"
77+ "io"
88+ "os"
99+ "os/signal"
1010+ "path/filepath"
1111+ "strings"
1212+ "time"
1313+1414+ "github.com/quailyquaily/mistermorph/agent"
1515+ "github.com/quailyquaily/mistermorph/llm"
1616+)
1717+1818+func handleInitRead(writer io.Writer, agentsPath string) bool {
1919+ if _, err := os.Stat(agentsPath); err == nil {
2020+ data, err := os.ReadFile(agentsPath)
2121+ if err != nil {
2222+ _, _ = fmt.Fprintf(writer, "Error reading AGENTS.md: %v\n", err)
2323+ } else {
2424+ _, _ = fmt.Fprintln(writer, "\n--- AGENTS.md ---")
2525+ _, _ = fmt.Fprintln(writer, string(data))
2626+ _, _ = fmt.Fprintln(writer, "-----------------")
2727+ }
2828+ return true
2929+ }
3030+ return false
3131+}
3232+3333+func handleAgentsGenerate(
3434+ writer io.Writer,
3535+ input string,
3636+ chatFileCacheDir string,
3737+ timeout time.Duration,
3838+ engine *agent.Engine,
3939+ model string,
4040+ history []llm.Message,
4141+) ([]llm.Message, bool) {
4242+ agentsPath := filepath.Join(chatFileCacheDir, "AGENTS.md")
4343+ isUpdate := strings.ToLower(input) == "/update"
4444+ if isUpdate {
4545+ _, _ = fmt.Fprintln(writer, "\033[33m⚙️ Regenerating AGENTS.md...\033[0m")
4646+ }
4747+ stopInitAnim, _ := thinkingAnimation(writer)
4848+ initCtx, initCancel := context.WithCancel(context.Background())
4949+ go func() {
5050+ <-time.After(timeout)
5151+ initCancel()
5252+ }()
5353+ sigCh := make(chan os.Signal, 1)
5454+ signal.Notify(sigCh, os.Interrupt)
5555+ go func() {
5656+ select {
5757+ case <-sigCh:
5858+ initCancel()
5959+ case <-initCtx.Done():
6060+ }
6161+ signal.Stop(sigCh)
6262+ }()
6363+ initPrompt := fmt.Sprintf(`Please analyze the project in directory %q and generate an AGENTS.md file.
6464+6565+AGENTS.md is a project-level guide for AI coding assistants. It should contain:
6666+6767+1. **Project Overview** — what this project does, its purpose, tech stack
6868+2. **Directory Structure** — key directories and their purposes
6969+3. **Build & Development** — how to build, test, run
7070+4. **Coding Conventions** — naming, formatting, architecture patterns
7171+5. **Key Dependencies** — major libraries/frameworks
7272+6. **Special Notes** — anything AI assistants should know (env vars, config files, gotchas)
7373+7474+Use bash and read_file tools to explore the project structure, README, go.mod, package.json, Makefile, etc. to gather accurate information.
7575+7676+IMPORTANT: Do NOT use the write_file tool. Instead, write the final AGENTS.md content directly as your response text. Use markdown format. Be concise but thorough.`, chatFileCacheDir)
7777+ final, _, err := engine.Run(initCtx, initPrompt, agent.RunOptions{
7878+ Model: strings.TrimSpace(model),
7979+ Scene: "chat.init",
8080+ History: append([]llm.Message(nil), history...),
8181+ })
8282+ stopInitAnim()
8383+ initCancel()
8484+ if err != nil {
8585+ if errors.Is(err, context.Canceled) {
8686+ _, _ = fmt.Fprintln(writer, "\n\033[33m⚡ Interrupted.\033[0m")
8787+ return history, false
8888+ }
8989+ _, _ = fmt.Fprintf(writer, "Error generating AGENTS.md: %v\n", err)
9090+ return history, false
9191+ }
9292+ content := formatChatOutput(final)
9393+ if content == "" {
9494+ _, _ = fmt.Fprintln(writer, "AI returned empty content. AGENTS.md not created.")
9595+ return history, false
9696+ }
9797+ content = stripMarkdownFences(content)
9898+ if err := os.WriteFile(agentsPath, []byte(content), 0o644); err != nil {
9999+ _, _ = fmt.Fprintf(writer, "Error writing AGENTS.md: %v\n", err)
100100+ return history, false
101101+ }
102102+ if isUpdate {
103103+ _, _ = fmt.Fprintf(writer, "\033[32m✓ AGENTS.md updated at %s\033[0m\n", agentsPath)
104104+ } else {
105105+ _, _ = fmt.Fprintf(writer, "\033[32m✓ AGENTS.md created at %s\033[0m\n", agentsPath)
106106+ }
107107+ _, _ = fmt.Fprintln(writer, "\n--- AGENTS.md ---")
108108+ _, _ = fmt.Fprintln(writer, content)
109109+ _, _ = fmt.Fprintln(writer, "-----------------")
110110+ history = append(history, llm.Message{Role: "user", Content: fmt.Sprintf("I have initialized this project. Here is the AGENTS.md for this project:\n\n%s", content)})
111111+ history = append(history, llm.Message{Role: "assistant", Content: "Got it. I've read the AGENTS.md and understand this project's structure, conventions, and guidelines. I'm ready to help."})
112112+ return history, true
113113+}
···11+// Package chatcommands provides a unified slash-command dispatcher that can be
22+// reused by the TUI chat, Telegram, and Slack runtimes.
33+package chatcommands
44+55+import (
66+ "context"
77+ "strings"
88+ "sync"
99+)
1010+1111+// Result is the return value of a command handler.
1212+type Result struct {
1313+ Reply string
1414+ Quit bool
1515+}
1616+1717+// Handler is the signature for a command handler.
1818+// The args string contains everything after the command word (already trimmed).
1919+// The returned *Result carries reply text and optional quit flag; an error signals a handler failure.
2020+type Handler func(ctx context.Context, args string) (*Result, error)
2121+2222+// Registry maps command names (e.g. "/help") to their handlers.
2323+type Registry struct {
2424+ mu sync.RWMutex
2525+ handlers map[string]Handler
2626+}
2727+2828+// NewRegistry creates an empty Registry.
2929+func NewRegistry() *Registry {
3030+ return &Registry{
3131+ handlers: make(map[string]Handler),
3232+ }
3333+}
3434+3535+// Register binds a command name to a handler. The name is normalised with
3636+// NormalizeCommand before storage, so callers may pass "/help" or "/help@Bot".
3737+// Registering the same name twice overwrites the previous handler.
3838+func (r *Registry) Register(name string, h Handler) {
3939+ name = NormalizeCommand(name)
4040+ if name == "" {
4141+ return
4242+ }
4343+ r.mu.Lock()
4444+ defer r.mu.Unlock()
4545+ r.handlers[name] = h
4646+}
4747+4848+// Lookup returns the handler for a normalised command name, or nil.
4949+func (r *Registry) Lookup(name string) Handler {
5050+ name = NormalizeCommand(name)
5151+ if name == "" {
5252+ return nil
5353+ }
5454+ r.mu.RLock()
5555+ defer r.mu.RUnlock()
5656+ return r.handlers[name]
5757+}
5858+5959+// Dispatch parses text into a command word and arguments, looks up the
6060+// registered handler, and invokes it. If the text does not start with a
6161+// recognised command, result == nil, handled == false and err == nil.
6262+func (r *Registry) Dispatch(ctx context.Context, text string) (result *Result, handled bool, err error) {
6363+ cmd, args := ParseCommand(text)
6464+ if cmd == "" {
6565+ return nil, false, nil
6666+ }
6767+ h := r.Lookup(cmd)
6868+ if h == nil {
6969+ return nil, false, nil
7070+ }
7171+ result, err = h(ctx, args)
7272+ return result, true, err
7373+}
7474+7575+// Names returns a sorted snapshot of all registered command names.
7676+func (r *Registry) Names() []string {
7777+ r.mu.RLock()
7878+ defer r.mu.RUnlock()
7979+ out := make([]string, 0, len(r.handlers))
8080+ for name := range r.handlers {
8181+ out = append(out, name)
8282+ }
8383+ // Simple bubble sort for deterministic output without importing sort.
8484+ for i := 0; i < len(out); i++ {
8585+ for j := i + 1; j < len(out); j++ {
8686+ if out[i] > out[j] {
8787+ out[i], out[j] = out[j], out[i]
8888+ }
8989+ }
9090+ }
9191+ return out
9292+}
9393+9494+// ParseCommand splits a raw message into (cmd, args). If text does not start
9595+// with a "/" command, cmd is empty. The command word is NOT normalised here so
9696+// callers can choose whether to apply bot-mention stripping separately.
9797+func ParseCommand(text string) (cmd string, args string) {
9898+ text = strings.TrimSpace(text)
9999+ if text == "" {
100100+ return "", ""
101101+ }
102102+ i := strings.IndexAny(text, " \n\t")
103103+ if i == -1 {
104104+ return text, ""
105105+ }
106106+ return text[:i], strings.TrimSpace(text[i:])
107107+}
108108+109109+// NormalizeCommand strips a trailing "@bot" suffix (used by Telegram and
110110+// sometimes Slack) and lower-cases the word. It returns "" for non-command
111111+// input (i.e. strings that do not start with "/").
112112+func NormalizeCommand(word string) string {
113113+ word = strings.TrimSpace(word)
114114+ if word == "" || !strings.HasPrefix(word, "/") {
115115+ return ""
116116+ }
117117+ if at := strings.IndexByte(word, '@'); at >= 0 {
118118+ word = word[:at]
119119+ }
120120+ return strings.ToLower(word)
121121+}
···11+package chatcommands
22+33+import (
44+ "context"
55+ "strings"
66+77+ "github.com/quailyquaily/mistermorph/internal/llmselect"
88+ "github.com/quailyquaily/mistermorph/internal/llmutil"
99+)
1010+1111+// HelpHandler returns a Handler that replies with a list of registered commands.
1212+// The optional header is printed before the command list.
1313+func HelpHandler(r *Registry, header string) Handler {
1414+ return func(ctx context.Context, args string) (*Result, error) {
1515+ names := r.Names()
1616+ var b strings.Builder
1717+ if header != "" {
1818+ b.WriteString(header)
1919+ b.WriteString("\n")
2020+ }
2121+ if len(names) == 0 {
2222+ b.WriteString("No commands available.")
2323+ return &Result{Reply: b.String()}, nil
2424+ }
2525+ for _, name := range names {
2626+ if b.Len() > 0 && b.String()[b.Len()-1] != '\n' {
2727+ b.WriteString("\n")
2828+ }
2929+ b.WriteString(" ")
3030+ b.WriteString(name)
3131+ }
3232+ return &Result{Reply: b.String()}, nil
3333+ }
3434+}
3535+3636+// EchoHandler returns a Handler that echoes back its arguments.
3737+func EchoHandler() Handler {
3838+ return func(ctx context.Context, args string) (*Result, error) {
3939+ if args == "" {
4040+ return &Result{Reply: "usage: /echo <msg>"}, nil
4141+ }
4242+ return &Result{Reply: args}, nil
4343+ }
4444+}
4545+4646+// ModelHandler wraps the llmselect package so that /model commands can be
4747+// handled uniformly across chat front-ends.
4848+//
4949+// The raw text passed to the handler should be the full user message (e.g.
5050+// "/model set foo" or "/model@Bot set foo"). The handler normalises the
5151+// command word internally.
5252+type ModelHandler struct {
5353+ Values llmutil.RuntimeValues
5454+ Store *llmselect.Store
5555+}
5656+5757+// NewModelHandler creates a ModelHandler backed by the given runtime values and
5858+// selection store. If store is nil a fresh one is allocated.
5959+func NewModelHandler(values llmutil.RuntimeValues, store *llmselect.Store) *ModelHandler {
6060+ if store == nil {
6161+ store = llmselect.NewStore()
6262+ }
6363+ return &ModelHandler{Values: values, Store: store}
6464+}
6565+6666+// Handle implements the Handler signature for /model commands.
6767+func (m *ModelHandler) Handle(ctx context.Context, text string) (*Result, error) {
6868+ output, handled, err := llmselect.ExecuteCommandText(m.Values, m.Store, text)
6969+ if !handled {
7070+ return nil, nil
7171+ }
7272+ return &Result{Reply: output}, err
7373+}
7474+7575+// AsHandler returns the model handler as a standard Handler closure so it can
7676+// be registered in a Registry.
7777+func (m *ModelHandler) AsHandler() Handler {
7878+ return func(ctx context.Context, text string) (*Result, error) {
7979+ return m.Handle(ctx, text)
8080+ }
8181+}