| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "strconv" |
| 7 | "strings" |
| 8 | |
| 9 | "reasonix/internal/config" |
| 10 | "reasonix/internal/i18n" |
| 11 | "reasonix/internal/migration" |
| 12 | "reasonix/internal/pluginpkg" |
| 13 | "reasonix/internal/skill" |
| 14 | ) |
| 15 | |
| 16 | // SlashItem is one slash-completion suggestion. Insert is the token text placed |
| 17 | // at the current argument position (callers replace from the token's start, see |
| 18 | // SlashArgItems' returned offset); Descend hints the menu to re-open one level |
| 19 | // deeper after accepting (e.g. "/mcp " → "/mcp add "). |
| 20 | type SlashItem struct { |
| 21 | Label string `json:"label"` |
| 22 | Insert string `json:"insert"` |
| 23 | Hint string `json:"hint"` |
| 24 | Descend bool `json:"descend"` |
| 25 | } |
| 26 | |
| 27 | // ArgData supplies the dynamic data SlashArgItems needs, so the completion logic |
| 28 | // is one shared function both frontends call with their own session data — the |
| 29 | // chat TUI (controller-free, from its cached lists) and the desktop (from the |
| 30 | // controller). This keeps the CLI and desktop sub-command hints identical. |
| 31 | type ArgData struct { |
| 32 | Skills []skill.Skill |
| 33 | DisabledSkills []skill.Skill |
| 34 | ServerNames []string |
| 35 | ConfiguredMCP []string |
| 36 | DisconnectedMCP []string |
| 37 | ModelRefs []string |
| 38 | CurrentModel string |
| 39 | ProviderNames []string |
| 40 | CurrentProvider string |
| 41 | PluginNames []string |
| 42 | MemoryRefs []string |
| 43 | MemoryArchives []string |
| 44 | } |
| 45 | |
| 46 | // SlashArgItems completes the arguments of a management slash command |
| 47 | // (everything after the command word). It returns the suggestions filtered by |
| 48 | // the token being typed and the byte offset where that token begins, so a caller |
| 49 | // replaces just that token. Only structured commands participate (/mcp /model |
| 50 | // /skills /plugins /hooks /effort /goal /reasoning-language |
| 51 | // /theme /language /currency /memory); |
| 52 | // others yield nil. Single source of truth for CLI + desktop. |
| 53 | func SlashArgItems(line string, d ArgData) ([]SlashItem, int) { |
| 54 | cmdEnd := strings.IndexAny(line, " \t") |
| 55 | if cmdEnd < 0 { |
| 56 | return nil, 0 |
| 57 | } |
| 58 | from := strings.LastIndexAny(line, " \t") + 1 |
| 59 | cur := line[from:] |
| 60 | prior := strings.Fields(line[:from]) // committed tokens, including the command word |
| 61 | var raw []SlashItem |
| 62 | switch line[:cmdEnd] { |
| 63 | case "/mcp": |
| 64 | raw = mcpArgItems(prior, cur, d) |
| 65 | case "/model": |
| 66 | raw = modelArgItems(prior, d) |
| 67 | case "/provider": |
| 68 | raw = providerArgItems(prior, d) |
| 69 | case "/skill", "/skills": |
| 70 | raw = skillArgItems(prior, d) |
| 71 | case "/plugin", "/plugins": |
| 72 | raw = pluginArgItems(prior, d) |
| 73 | case "/hooks": |
| 74 | raw = hooksArgItems(prior) |
| 75 | case "/effort": |
| 76 | raw = effortArgItems(prior, d) |
| 77 | case "/goal": |
| 78 | raw = goalArgItems(prior) |
| 79 | case "/reasoning-language": |
| 80 | raw = reasoningLanguageArgItems(prior) |
| 81 | case "/theme": |
| 82 | raw = themeArgItems(prior) |
| 83 | case "/language": |
| 84 | raw = languageArgItems(prior) |
| 85 | case "/currency": |
| 86 | raw = currencyArgItems(prior) |
| 87 | case "/memory": |
| 88 | raw = memoryArgItems(prior, d) |
| 89 | default: |
| 90 | return nil, from |
| 91 | } |
| 92 | return filterSlash(raw, line, from, cur), from |
| 93 | } |
| 94 | |
| 95 | func memoryArgItems(prior []string, d ArgData) []SlashItem { |
| 96 | if len(prior) <= 1 { |
| 97 | return []SlashItem{ |
| 98 | {Label: "recall", Insert: "recall", Hint: "show the latest automatic recall decision"}, |
| 99 | {Label: "revisions", Insert: "revisions ", Hint: "show revision history", Descend: true}, |
| 100 | {Label: "restore", Insert: "restore ", Hint: "restore an older revision", Descend: true}, |
| 101 | {Label: "archived", Insert: "archived", Hint: "show archived facts"}, |
| 102 | {Label: "recover", Insert: "recover ", Hint: "recover an archived fact", Descend: true}, |
| 103 | {Label: "instructions", Insert: "instructions", Hint: "show precedence, imports, and diagnostics"}, |
| 104 | } |
| 105 | } |
| 106 | switch prior[1] { |
| 107 | case "revisions", "restore": |
| 108 | if len(prior) != 2 { |
| 109 | return nil |
| 110 | } |
| 111 | items := make([]SlashItem, 0, len(d.MemoryRefs)) |
| 112 | for _, ref := range d.MemoryRefs { |
| 113 | items = append(items, SlashItem{Label: ref, Insert: ref}) |
| 114 | } |
| 115 | return items |
| 116 | case "recover": |
| 117 | if len(prior) != 2 { |
| 118 | return nil |
| 119 | } |
| 120 | items := make([]SlashItem, 0, len(d.MemoryArchives)) |
| 121 | for _, path := range d.MemoryArchives { |
| 122 | items = append(items, SlashItem{Label: path, Insert: `"` + path + `"`}) |
| 123 | } |
| 124 | return items |
| 125 | } |
| 126 | return nil |
| 127 | } |
| 128 | |
| 129 | func goalArgItems(prior []string) []SlashItem { |
| 130 | if len(prior) > 1 { |
| 131 | return nil |
| 132 | } |
| 133 | return []SlashItem{ |
| 134 | {Label: "--research", Insert: "--research ", Hint: "force durable AutoResearch state"}, |
| 135 | {Label: "--simple", Insert: "--simple ", Hint: "force lightweight Goal"}, |
| 136 | {Label: "status", Insert: "status", Hint: "show active goal and budget runtime"}, |
| 137 | {Label: "pause", Insert: "pause", Hint: "pause the running goal (keeps all state)"}, |
| 138 | {Label: "resume", Insert: "resume", Hint: "resume a paused goal (adds one turn slice)"}, |
| 139 | {Label: "clear", Insert: "clear", Hint: "stop goal mode"}, |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | func reasoningLanguageArgItems(prior []string) []SlashItem { |
| 144 | if len(prior) > 1 { |
| 145 | return nil |
| 146 | } |
| 147 | return []SlashItem{ |
| 148 | {Label: "auto", Insert: "auto", Hint: "follow conversation language"}, |
| 149 | {Label: "zh", Insert: "zh", Hint: "prefer Chinese visible reasoning"}, |
| 150 | {Label: "en", Insert: "en", Hint: "prefer English visible reasoning"}, |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | func languageArgItems(prior []string) []SlashItem { |
| 155 | if len(prior) > 1 { |
| 156 | return nil |
| 157 | } |
| 158 | return []SlashItem{ |
| 159 | {Label: "auto", Insert: "auto", Hint: i18n.M.ArgLanguageAuto}, |
| 160 | {Label: "en", Insert: "en", Hint: i18n.M.ArgLanguageEn}, |
| 161 | {Label: "zh", Insert: "zh", Hint: i18n.M.ArgLanguageZh}, |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | func currencyArgItems(prior []string) []SlashItem { |
| 166 | if len(prior) != 1 { |
| 167 | return nil |
| 168 | } |
| 169 | return []SlashItem{ |
| 170 | {Label: "auto", Insert: "auto", Hint: "follow the resolved CLI locale"}, |
| 171 | {Label: "CNY", Insert: "CNY", Hint: "Chinese yuan pricing"}, |
| 172 | {Label: "USD", Insert: "USD", Hint: "US dollar pricing"}, |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | func themeArgItems(prior []string) []SlashItem { |
| 177 | if len(prior) > 1 { |
| 178 | return nil |
| 179 | } |
| 180 | items := []SlashItem{ |
| 181 | {Label: "auto", Insert: "auto", Hint: "mode · detect system or terminal background"}, |
| 182 | {Label: "light", Insert: "light", Hint: "mode · force light shell"}, |
| 183 | {Label: "dark", Insert: "dark", Hint: "mode · force dark shell"}, |
| 184 | } |
| 185 | for _, st := range []struct { |
| 186 | name string |
| 187 | mode string |
| 188 | desc string |
| 189 | }{ |
| 190 | {"graphite", "dark", "warm clay accent"}, |
| 191 | {"ember", "dark", "hot orange accent"}, |
| 192 | {"aurora", "dark", "cool teal accent"}, |
| 193 | {"midnight", "dark", "quiet violet accent"}, |
| 194 | {"sandstone", "light", "default warm light accent"}, |
| 195 | {"porcelain", "light", "soft violet light accent"}, |
| 196 | {"linen", "light", "muted coral light accent"}, |
| 197 | {"glacier", "light", "cool blue accent"}, |
| 198 | } { |
| 199 | items = append(items, SlashItem{Label: st.name, Insert: st.name, Hint: st.mode + " · " + st.desc}) |
| 200 | } |
| 201 | return items |
| 202 | } |
| 203 | |
| 204 | func effortArgItems(prior []string, d ArgData) []SlashItem { |
| 205 | if len(prior) <= 1 { |
| 206 | entry := currentEffortEntry(d) |
| 207 | cap := config.EffortCapabilityForEntry(entry) |
| 208 | var out []SlashItem |
| 209 | for _, level := range cap.Levels { |
| 210 | hint := "" |
| 211 | switch level { |
| 212 | case "auto": |
| 213 | hint = i18n.M.ArgEffortAuto |
| 214 | case "low": |
| 215 | hint = i18n.M.ArgEffortLow |
| 216 | case "medium": |
| 217 | hint = i18n.M.ArgEffortMedium |
| 218 | case "high": |
| 219 | hint = i18n.M.ArgEffortHigh |
| 220 | case "xhigh": |
| 221 | hint = i18n.M.ArgEffortXHigh |
| 222 | case "max": |
| 223 | hint = i18n.M.ArgEffortMax |
| 224 | } |
| 225 | out = append(out, SlashItem{Label: level, Insert: level, Hint: hint}) |
| 226 | } |
| 227 | return out |
| 228 | } |
| 229 | return nil |
| 230 | } |
| 231 | |
| 232 | func currentEffortEntry(d ArgData) *config.ProviderEntry { |
| 233 | if strings.TrimSpace(d.CurrentModel) == "" { |
| 234 | return nil |
| 235 | } |
| 236 | cfg, err := config.Load() |
| 237 | if err != nil { |
| 238 | return nil |
| 239 | } |
| 240 | entry, _ := cfg.ResolveModel(d.CurrentModel) |
| 241 | return entry |
| 242 | } |
| 243 | |
| 244 | func mcpArgItems(prior []string, cur string, d ArgData) []SlashItem { |
| 245 | if len(prior) <= 1 { |
| 246 | return []SlashItem{ |
| 247 | {Label: "add", Insert: "add ", Hint: i18n.M.ArgMcpAdd, Descend: true}, |
| 248 | {Label: "connect", Insert: "connect ", Hint: "connect a configured MCP server", Descend: true}, |
| 249 | {Label: "show", Insert: "show ", Hint: "show MCP server details", Descend: true}, |
| 250 | {Label: "tools", Insert: "tools ", Hint: "show MCP server tools", Descend: true}, |
| 251 | {Label: "remove", Insert: "remove ", Hint: i18n.M.ArgMcpRemove, Descend: true}, |
| 252 | {Label: "import", Insert: "import", Hint: "import MCP servers from cc-switch"}, |
| 253 | } |
| 254 | } |
| 255 | switch prior[1] { |
| 256 | case "remove", "rm": |
| 257 | if len(prior) != 2 { // the single name arg is already placed |
| 258 | return nil |
| 259 | } |
| 260 | var items []SlashItem |
| 261 | for _, name := range d.ServerNames { |
| 262 | items = append(items, SlashItem{Label: name, Insert: name, Hint: i18n.M.ArgMcpConnected}) |
| 263 | } |
| 264 | return items |
| 265 | case "show", "tools": |
| 266 | if len(prior) != 2 { |
| 267 | return nil |
| 268 | } |
| 269 | var items []SlashItem |
| 270 | for _, name := range allMCPArgNames(d) { |
| 271 | items = append(items, SlashItem{Label: name, Insert: name}) |
| 272 | } |
| 273 | return items |
| 274 | case "connect": |
| 275 | if len(prior) != 2 { |
| 276 | return nil |
| 277 | } |
| 278 | var items []SlashItem |
| 279 | for _, name := range d.DisconnectedMCP { |
| 280 | items = append(items, SlashItem{Label: name, Insert: name, Hint: "configured"}) |
| 281 | } |
| 282 | return items |
| 283 | case "add": |
| 284 | if strings.HasPrefix(cur, "-") { |
| 285 | return []SlashItem{ |
| 286 | {Label: "--http", Insert: "--http ", Hint: "Streamable HTTP URL"}, |
| 287 | {Label: "--sse", Insert: "--sse ", Hint: "legacy SSE URL"}, |
| 288 | {Label: "--env", Insert: "--env ", Hint: "KEY=VALUE (stdio)"}, |
| 289 | {Label: "--header", Insert: "--header ", Hint: "KEY=VALUE (remote)"}, |
| 290 | } |
| 291 | } |
| 292 | } |
| 293 | return nil |
| 294 | } |
| 295 | |
| 296 | func allMCPArgNames(d ArgData) []string { |
| 297 | seen := map[string]bool{} |
| 298 | var out []string |
| 299 | for _, list := range [][]string{d.ServerNames, d.ConfiguredMCP, d.DisconnectedMCP} { |
| 300 | for _, name := range list { |
| 301 | if strings.TrimSpace(name) == "" || seen[name] { |
| 302 | continue |
| 303 | } |
| 304 | seen[name] = true |
| 305 | out = append(out, name) |
| 306 | } |
| 307 | } |
| 308 | return out |
| 309 | } |
| 310 | |
| 311 | func modelArgItems(prior []string, d ArgData) []SlashItem { |
| 312 | if len(prior) != 1 { // the single ref arg is already placed |
| 313 | return nil |
| 314 | } |
| 315 | var items []SlashItem |
| 316 | for _, ref := range d.ModelRefs { |
| 317 | hint := "" |
| 318 | if ref == d.CurrentModel { |
| 319 | hint = i18n.M.ArgModelCurrent |
| 320 | } |
| 321 | items = append(items, SlashItem{Label: ref, Insert: ref, Hint: hint}) |
| 322 | } |
| 323 | return items |
| 324 | } |
| 325 | |
| 326 | func providerArgItems(prior []string, d ArgData) []SlashItem { |
| 327 | if len(prior) != 1 { // the single name arg is already placed |
| 328 | return nil |
| 329 | } |
| 330 | var items []SlashItem |
| 331 | for _, name := range d.ProviderNames { |
| 332 | hint := "" |
| 333 | if name == d.CurrentProvider { |
| 334 | hint = i18n.M.ArgModelCurrent |
| 335 | } |
| 336 | items = append(items, SlashItem{Label: name, Insert: name, Hint: hint}) |
| 337 | } |
| 338 | return items |
| 339 | } |
| 340 | |
| 341 | func skillArgItems(prior []string, d ArgData) []SlashItem { |
| 342 | if len(prior) <= 1 { |
| 343 | return []SlashItem{ |
| 344 | {Label: "show", Insert: "show ", Hint: i18n.M.ArgSkillShow, Descend: true}, |
| 345 | {Label: "enable", Insert: "enable ", Hint: "enable a disabled skill", Descend: true}, |
| 346 | {Label: "disable", Insert: "disable ", Hint: "disable an enabled skill", Descend: true}, |
| 347 | {Label: "new", Insert: "new ", Hint: i18n.M.ArgSkillNew}, |
| 348 | {Label: "paths", Insert: "paths", Hint: i18n.M.ArgSkillPaths}, |
| 349 | } |
| 350 | } |
| 351 | if (prior[1] == "show" || prior[1] == "cat") && len(prior) == 2 { |
| 352 | var items []SlashItem |
| 353 | for _, s := range d.Skills { |
| 354 | items = append(items, SlashItem{Label: s.Name, Insert: s.Name, Hint: string(s.Scope)}) |
| 355 | } |
| 356 | return items |
| 357 | } |
| 358 | if prior[1] == "disable" && len(prior) == 2 { |
| 359 | var items []SlashItem |
| 360 | for _, s := range d.Skills { |
| 361 | items = append(items, SlashItem{Label: s.Name, Insert: s.Name, Hint: string(s.Scope)}) |
| 362 | } |
| 363 | return items |
| 364 | } |
| 365 | if prior[1] == "enable" && len(prior) == 2 { |
| 366 | var items []SlashItem |
| 367 | for _, s := range d.DisabledSkills { |
| 368 | items = append(items, SlashItem{Label: s.Name, Insert: s.Name, Hint: string(s.Scope)}) |
| 369 | } |
| 370 | return items |
| 371 | } |
| 372 | return nil |
| 373 | } |
| 374 | |
| 375 | func pluginArgItems(prior []string, d ArgData) []SlashItem { |
| 376 | if len(prior) <= 1 { |
| 377 | return []SlashItem{ |
| 378 | {Label: "show", Insert: "show ", Hint: "show plugin capabilities and usage", Descend: true}, |
| 379 | } |
| 380 | } |
| 381 | if (prior[1] == "show" || prior[1] == "cat") && len(prior) == 2 { |
| 382 | var items []SlashItem |
| 383 | for _, name := range d.PluginNames { |
| 384 | items = append(items, SlashItem{Label: name, Insert: name}) |
| 385 | } |
| 386 | return items |
| 387 | } |
| 388 | return nil |
| 389 | } |
| 390 | |
| 391 | func hooksArgItems(prior []string) []SlashItem { |
| 392 | if len(prior) <= 1 { |
| 393 | return []SlashItem{ |
| 394 | {Label: "list", Insert: "list", Hint: i18n.M.ArgHooksList}, |
| 395 | } |
| 396 | } |
| 397 | return nil |
| 398 | } |
| 399 | |
| 400 | // filterSlash keeps items whose label starts with the typed token (case- |
| 401 | // insensitive) and drops no-op suggestions — ones whose insert wouldn't change |
| 402 | // the line because the token is already fully typed (e.g. "/skills list" offering |
| 403 | // "list"). Without this the menu lingers on a complete command and Enter keeps |
| 404 | // "accepting" the no-op instead of sending. |
| 405 | func filterSlash(items []SlashItem, line string, from int, cur string) []SlashItem { |
| 406 | lp := strings.ToLower(cur) |
| 407 | prefix := line[:from] |
| 408 | var out []SlashItem |
| 409 | for _, it := range items { |
| 410 | if !strings.HasPrefix(strings.ToLower(it.Label), lp) { |
| 411 | continue |
| 412 | } |
| 413 | if prefix+it.Insert == line { |
| 414 | continue // token already complete: nothing to add |
| 415 | } |
| 416 | out = append(out, it) |
| 417 | } |
| 418 | return out |
| 419 | } |
| 420 | |
| 421 | // managementNotice handles management slash commands on the Submit path (used by |
| 422 | // the desktop and HTTP frontends, which route raw input through Submit — the chat |
| 423 | // TUI has its own richer handlers). It emits Notice output and reports whether |
| 424 | // it handled the verb. Skills and custom commands are NOT here — those resolve |
| 425 | // to a turn in Submit. |
| 426 | func (c *Controller) managementNotice(trimmed string) bool { |
| 427 | fields := strings.Fields(trimmed) |
| 428 | if len(fields) == 0 { |
| 429 | return false |
| 430 | } |
| 431 | switch fields[0] { |
| 432 | case "/model": |
| 433 | c.notice(c.modelListText()) |
| 434 | case "/provider": |
| 435 | if len(fields) >= 2 { |
| 436 | c.notice(c.providerSwitchText(fields[1])) |
| 437 | } else { |
| 438 | c.notice(c.providerListText()) |
| 439 | } |
| 440 | case "/memory": |
| 441 | args := strings.TrimSpace(strings.TrimPrefix(trimmed, fields[0])) |
| 442 | c.notice(MemoryCommandText(c, args)) |
| 443 | case "/migrate", "/migration": |
| 444 | args := strings.TrimSpace(strings.TrimPrefix(trimmed, fields[0])) |
| 445 | migration.RunLegacyRescueCommand(args, c.sink) |
| 446 | case "/skill", "/skills": |
| 447 | sub := "" |
| 448 | if len(fields) >= 2 { |
| 449 | sub = strings.ToLower(fields[1]) |
| 450 | } |
| 451 | if len(fields) >= 3 && (sub == "enable" || sub == "disable") { |
| 452 | enabled := sub == "enable" |
| 453 | if err := c.SetSkillEnabled(fields[2], enabled); err != nil { |
| 454 | c.notice("skill " + sub + ": " + err.Error()) |
| 455 | } else if enabled { |
| 456 | c.notice("enabled skill " + fields[2] + " — restart or refresh the session for the prompt and tools to update") |
| 457 | } else { |
| 458 | c.notice("disabled skill " + fields[2] + " — restart or refresh the session for the prompt and tools to update") |
| 459 | } |
| 460 | return true |
| 461 | } |
| 462 | c.notice(c.skillListText()) |
| 463 | case "/plugin", "/plugins": |
| 464 | sub := "" |
| 465 | if len(fields) >= 2 { |
| 466 | sub = strings.ToLower(fields[1]) |
| 467 | } |
| 468 | switch sub { |
| 469 | case "", "list", "ls": |
| 470 | text, err := pluginpkg.InstalledListText(config.ReasonixHomeDir()) |
| 471 | if err != nil { |
| 472 | c.notice("plugins: " + err.Error()) |
| 473 | } else { |
| 474 | c.notice(text) |
| 475 | } |
| 476 | case "show", "cat": |
| 477 | if len(fields) < 3 { |
| 478 | c.notice("usage: /plugins show <name>") |
| 479 | return true |
| 480 | } |
| 481 | text, err := pluginpkg.InstalledShowText(config.ReasonixHomeDir(), fields[2]) |
| 482 | if err != nil { |
| 483 | c.notice("plugins: " + err.Error()) |
| 484 | } else { |
| 485 | c.notice(text) |
| 486 | } |
| 487 | default: |
| 488 | c.notice("unknown /plugins subcommand " + fields[1] + " - try: /plugins or /plugins show <name>") |
| 489 | } |
| 490 | case "/reload-cmd": |
| 491 | if c.Running() { |
| 492 | c.notice("wait for the current turn to finish, then retry /reload-cmd") |
| 493 | return true |
| 494 | } |
| 495 | if err := c.ReloadCommands(context.Background()); err != nil { |
| 496 | c.notice("reload-cmd: " + err.Error()) |
| 497 | } else { |
| 498 | visible := 0 |
| 499 | for _, cmd := range c.Commands() { |
| 500 | if !cmd.Hidden { |
| 501 | visible++ |
| 502 | } |
| 503 | } |
| 504 | c.notice("commands reloaded (" + strconv.Itoa(visible) + " available)") |
| 505 | } |
| 506 | case "/hooks": |
| 507 | sub := "" |
| 508 | if len(fields) >= 2 { |
| 509 | sub = strings.ToLower(fields[1]) |
| 510 | } |
| 511 | switch sub { |
| 512 | case "", "list", "ls": |
| 513 | c.notice(c.hookListText()) |
| 514 | case "trust": |
| 515 | // Backward-compatible response for old clients and saved commands. |
| 516 | c.notice("project hooks are enabled automatically; no trust action is required") |
| 517 | default: |
| 518 | c.notice("unknown /hooks subcommand " + fields[1] + " — try: /hooks or /hooks list") |
| 519 | } |
| 520 | case "/mcp": |
| 521 | if len(fields) >= 3 && fields[1] == "connect" { |
| 522 | n, err := c.ConnectConfiguredMCPServer(fields[2]) |
| 523 | if err != nil { |
| 524 | c.notice("mcp connect: " + err.Error()) |
| 525 | } else { |
| 526 | c.notice(fmt.Sprintf("connected %s — %d tools", fields[2], n)) |
| 527 | } |
| 528 | return true |
| 529 | } |
| 530 | c.notice(c.mcpListText()) |
| 531 | default: |
| 532 | return false |
| 533 | } |
| 534 | return true |
| 535 | } |
| 536 | |
| 537 | func (c *Controller) modelListText() string { |
| 538 | cfg, err := config.Load() |
| 539 | if err != nil { |
| 540 | return "model: " + err.Error() |
| 541 | } |
| 542 | var b strings.Builder |
| 543 | fmt.Fprintf(&b, i18n.M.ListModelsHeaderFmt+"\n", c.label) |
| 544 | for i := range cfg.Providers { |
| 545 | p := &cfg.Providers[i] |
| 546 | if !p.Configured() { |
| 547 | continue |
| 548 | } |
| 549 | for _, m := range p.ChatModelList() { |
| 550 | fmt.Fprintf(&b, " %s/%s\n", p.Name, m) |
| 551 | } |
| 552 | } |
| 553 | b.WriteString(i18n.M.ListModelsHint) |
| 554 | return strings.TrimRight(b.String(), "\n") |
| 555 | } |
| 556 | |
| 557 | func (c *Controller) providerListText() string { |
| 558 | cfg, err := config.Load() |
| 559 | if err != nil { |
| 560 | return "provider: " + err.Error() |
| 561 | } |
| 562 | curProvider := "" |
| 563 | if parts := strings.Fields(c.label); len(parts) > 0 { |
| 564 | curProvider = parts[0] |
| 565 | } |
| 566 | var b strings.Builder |
| 567 | b.WriteString(i18n.M.ProviderListHeader + "\n") |
| 568 | for i := range cfg.Providers { |
| 569 | p := &cfg.Providers[i] |
| 570 | if !p.Configured() { |
| 571 | continue |
| 572 | } |
| 573 | models := p.ChatModelList() |
| 574 | if len(models) == 0 { |
| 575 | models = p.ModelList() |
| 576 | } |
| 577 | suffix := "" |
| 578 | if p.Name == curProvider { |
| 579 | suffix = " (active)" |
| 580 | } |
| 581 | fmt.Fprintf(&b, " %s — %d models%s\n", p.Name, len(models), suffix) |
| 582 | } |
| 583 | b.WriteString("switch with /provider <name>") |
| 584 | return strings.TrimRight(b.String(), "\n") |
| 585 | } |
| 586 | |
| 587 | func (c *Controller) providerSwitchText(name string) string { |
| 588 | cfg, err := config.Load() |
| 589 | if err != nil { |
| 590 | return "provider: " + err.Error() |
| 591 | } |
| 592 | for i := range cfg.Providers { |
| 593 | p := &cfg.Providers[i] |
| 594 | if p.Name == name && p.Configured() { |
| 595 | models := p.ChatModelList() |
| 596 | if len(models) == 0 { |
| 597 | models = p.ModelList() |
| 598 | } |
| 599 | if len(models) == 0 { |
| 600 | return fmt.Sprintf(i18n.M.ProviderNoModelsFmt, name) |
| 601 | } |
| 602 | if len(models) == 1 { |
| 603 | return fmt.Sprintf("provider %s — model: %s (switch with /model %s/%s)", name, models[0], name, models[0]) |
| 604 | } |
| 605 | var b strings.Builder |
| 606 | fmt.Fprintf(&b, "provider %s — %d models:\n", name, len(models)) |
| 607 | for _, m := range models { |
| 608 | fmt.Fprintf(&b, " %s/%s\n", name, m) |
| 609 | } |
| 610 | fmt.Fprintf(&b, "switch with /model %s/<model>", name) |
| 611 | return strings.TrimRight(b.String(), "\n") |
| 612 | } |
| 613 | } |
| 614 | return fmt.Sprintf(i18n.M.ProviderUnknownFmt, name) |
| 615 | } |
| 616 | |
| 617 | func (c *Controller) skillListText() string { |
| 618 | skills := c.skills.discovered() |
| 619 | if len(skills) == 0 { |
| 620 | return i18n.M.ListSkillsNone |
| 621 | } |
| 622 | var b strings.Builder |
| 623 | fmt.Fprintf(&b, i18n.M.ListSkillsHeaderFmt+"\n", len(skills)) |
| 624 | for _, s := range skills { |
| 625 | tag := "" |
| 626 | if s.RunAs == "subagent" { |
| 627 | tag = " 🧬" |
| 628 | } |
| 629 | fmt.Fprintf(&b, " /%s%s — %s\n", s.Name, tag, s.Description) |
| 630 | } |
| 631 | return strings.TrimRight(b.String(), "\n") |
| 632 | } |
| 633 | |
| 634 | func (c *Controller) hookListText() string { |
| 635 | hooks := c.hooks.Hooks() |
| 636 | if len(hooks) == 0 { |
| 637 | return i18n.M.ListHooksNone |
| 638 | } |
| 639 | var b strings.Builder |
| 640 | fmt.Fprintf(&b, i18n.M.ListHooksHeaderFmt+"\n", len(hooks)) |
| 641 | for _, h := range hooks { |
| 642 | match := h.Match |
| 643 | if match == "" { |
| 644 | match = "*" |
| 645 | } |
| 646 | fmt.Fprintf(&b, " %s [%s] %s — %s\n", h.Event, h.Scope, match, h.Command) |
| 647 | } |
| 648 | return strings.TrimRight(b.String(), "\n") |
| 649 | } |
| 650 | |
| 651 | func (c *Controller) mcpListText() string { |
| 652 | names := c.mcp.serverNames() |
| 653 | if len(names) == 0 && len(c.mcp.failures()) == 0 { |
| 654 | return i18n.M.ListMcpNone |
| 655 | } |
| 656 | var b strings.Builder |
| 657 | if len(names) > 0 { |
| 658 | b.WriteString(i18n.M.ListMcpHeader + "\n") |
| 659 | for _, name := range names { |
| 660 | fmt.Fprintf(&b, " %s\n", name) |
| 661 | } |
| 662 | } |
| 663 | if failures := c.mcp.failures(); len(failures) > 0 { |
| 664 | if b.Len() > 0 { |
| 665 | b.WriteString("\n") |
| 666 | } |
| 667 | b.WriteString("MCP startup failures:\n") |
| 668 | for _, f := range failures { |
| 669 | fmt.Fprintf(&b, " %s (%s): %s\n", f.Name, f.Transport, f.Error) |
| 670 | } |
| 671 | } |
| 672 | return strings.TrimRight(b.String(), "\n") |
| 673 | } |
| 674 |