package tui import ( "fmt" "sort" "strings" "github.com/charmbracelet/lipgloss" "codey/internal/skills" ) // command is one thing the prompt box accepts with a leading slash: either a // control the TUI answers itself, or a skill the agent is asked to invoke. type command struct { name string // without the slash args string // what may follow the name; "" when it takes none desc string tag string // where a skill came from — "personal", "project", "claude:"; "" for a built-in } // builtins are the commands submit answers without going near the model. The // order is the order the menu offers them in — most reached-for first, not // alphabetical, because the first row is the one a person lands on. // // This list is also what /help prints and what the completion menu offers, so // none of the three can drift away from the others. var builtins = []command{ {name: "help", desc: "the commands and the keys"}, {name: "skills", desc: "the instruction sets found at startup"}, {name: "model", args: "id", desc: "switch model — lists what the endpoint serves"}, {name: "effort", args: "level", desc: "how hard the model thinks — opens a slider"}, {name: "settings", desc: "change the saved defaults, a row at a time"}, {name: "setup", desc: "the full-screen wizard for the saved defaults"}, // The argument is spelled as the common case rather than as the whole // grammar: "name|task|resume" set the width of the entire help column on // its own, and the alternatives are in the description where there is room // to say what they do. {name: "worktree", args: "name", desc: "branch off into a worktree — resume opens one"}, {name: "compact", desc: "fold the conversation into a summary now"}, {name: "clear", desc: "empty the transcript"}, {name: "resume", args: "id", desc: "switch to a session saved here — opens a chooser"}, {name: "new", desc: "open a fresh session in another tab — ctrl+t does the same"}, {name: "close", desc: "close the tab on screen — alt+w does the same"}, {name: "image", args: "path", desc: "attach a picture — bare, it takes the clipboard's"}, {name: "mouse", desc: "hand the mouse back to the terminal, or take it again"}, {name: "allowed", desc: "what is permanently allowed this session"}, {name: "exit", desc: "leave Codey"}, } // commands is the catalogue: the built-ins, then every discovered skill. func (m *model) commands() []command { out := make([]command, 0, len(builtins)+len(m.opts.Skills)) out = append(out, builtins...) for _, s := range m.opts.Skills { desc := s.Description if desc == "" { desc = "a skill with no description" } out = append(out, command{name: s.Name, args: "arguments", desc: desc, tag: s.Source}) } return out } // matchCommands narrows the catalogue to what has been typed after the slash. // A name that starts with it comes before one that merely contains it, so // "/sk" offers /skills before a skill called "ask-the-docs". func matchCommands(all []command, typed string) []command { if typed == "" { return append([]command(nil), all...) } var prefix, contains []command for _, c := range all { switch { case strings.HasPrefix(c.name, typed): prefix = append(prefix, c) case strings.Contains(c.name, typed): contains = append(contains, c) } } return append(prefix, contains...) } // skillsText is what /skills prints: the discovered skills grouped by where // they came from, names in a column, one line each. // // Grouped rather than listed flat because the answer to "what is loaded?" // stopped being four lines the moment Claude Code's skills could be read too — // twenty-five rows each ending in "(claude:superpowers)" is the same fact // printed twenty-five times. One line each because a description written for // another harness can be a paragraph, and this list is for recognising a skill // you already know about; the file is where the rest of it is. func (m *model) skillsText() string { width := maxInt(40, m.mainWidth()) // The name column is measured across every group, so the descriptions // line up down the whole list rather than per group. It is capped, or one // long skill name would push every description off the screen. names := map[string][]skills.Skill{} col := 0 for _, s := range m.opts.Skills { src := s.Source if src == "" { src = "unknown" } names[src] = append(names[src], s) col = maxInt(col, lipgloss.Width("/"+s.Name)) } col = minInt(col, maxInt(12, width/3)) var b strings.Builder fmt.Fprintf(&b, "skills: %d", len(m.opts.Skills)) for _, src := range skillSources(names) { b.WriteString("\n " + src) for _, s := range names[src] { desc := s.Description if desc == "" { desc = "no description, so the model has nothing to trigger on" } line := " " + pad("/"+s.Name, col) + " " + desc b.WriteString("\n" + truncateStyled(line, width)) } } return b.String() } // skillSources orders the groups: Codey's own first, because they are the ones // someone here put there, then Claude's by name. Alphabetical throughout would // open the list with a plugin nobody in this project chose. func skillSources(groups map[string][]skills.Skill) []string { var rest []string for src := range groups { switch src { case "personal", "project": default: rest = append(rest, src) } } sort.Strings(rest) var out []string for _, first := range []string{"personal", "project"} { if len(groups[first]) > 0 { out = append(out, first) } } return append(out, rest...) } // helpText is built from the catalogue rather than written out beside it. func (m *model) helpText() string { // The name column is measured rather than guessed at. A fixed width was // one long argument list away from a description that started in the wrong // place, and /worktree was that argument list. names := make([]string, 0, len(builtins)) width := 0 for _, c := range builtins { name := "/" + c.name if c.args != "" { name += " <" + c.args + ">" } names = append(names, name) width = maxInt(width, len(name)) } var b strings.Builder b.WriteString("commands:") for i, c := range builtins { fmt.Fprintf(&b, "\n %-*s %s", width, names[i], c.desc) } if n := len(m.opts.Skills); n > 0 { fmt.Fprintf(&b, "\n %-*s %s", width, "/", plural(n, "discovered skill")+" — /skills lists them") } // The rest is the same three subjects it always was — the keys, the mouse, // the pictures — but in a column with a blank line between the sections. // What changed is that you can find the one line you came for without // reading the other twenty, which is most of what a help screen is for. b.WriteString("\n\nkeys:" + helpKey("enter", "send · ctrl+j starts another line instead") + helpKey("↑ ↓", "walk back through what has been typed here before —") + helpKey("", "esc puts the half-written prompt back") + helpKey("/", "the command menu · ↑↓ move · tab or enter completes") + helpKey("! $", "a shell or a python line, on an empty prompt — what") + helpKey("", "they print goes to the agent too, so you can then") + helpKey("", "say \"fix that\"") + helpKey("shift+tab", "cycle the approval mode") + helpKey("ctrl+b", "the agents sidebar — ↑↓ move · enter reads one ·") + helpKey("", "p pauses · k kills") + helpKey("ctrl+t", "a new session in a tab · alt+1…9 goes to one ·") + helpKey("", "alt+w closes one · ctrl+pgup/pgdn walks them") + helpKey("ctrl+r", "show full tool output · pgup/pgdn scroll") + helpKey("esc", "interrupt the turn · ctrl+c again to exit")) b.WriteString("\n\nmouse:" + helpKey("wheel", "scrolls · drag selects and copies on release") + helpKey("click", "a menu row, a model, the slider or an approval picks") + helpKey("", "it · the prompt, to type with the sidebar open") + helpKey("double", "selects a word · triple a line · shift+click extends") + helpKey("/mouse", "hands the mouse back to the terminal's own selection")) b.WriteString("\n\nimages:" + helpKey("ctrl+v", "pastes the clipboard's picture in as [image 1], and") + helpKey("", "so does dropping a file on the window — the marker is") + helpKey("", "where it goes, and deleting it takes the paste back") + helpKey("a path", "left in the prompt becomes the picture when you send") + helpKey("/image", " is the way that works on any terminal")) return b.String() } // helpKey is one row of the key column. An empty name continues the row above, // which is how a two-line answer stays inside its own column. func helpKey(name, desc string) string { return fmt.Sprintf("\n %-11s %s", name, desc) }