返回 DeepSeek-Reasonix
slash.go
根目录 / internal / control / slash.go
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
674 lines GO