package tui
import (
"fmt"
"sort"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/johannSo/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:<plugin>"; "" 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, "/<skill>", 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", "<path> 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)
}