Vectrum
Sign In
214 lines · 8.5 KB · Go
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:<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)
}