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