Files
LLeMbas-CLI/src/session/commands.ts
T
HomerandClaude Opus 5.5 fbefe7f844
ci / check (push) Waiting to run
Harness spec 3.2.0 in the CLI: effort levels, reasoning round-trip, no absolute paths
Levels (models/schema.json levels): provider/effort.ts levelsOf/levelFields/
readLevel — `off` only with thinking_switch and sends enable_thinking false
and no effort, `default` for a model that takes no efforts and sends
nothing, a level not offered is read as the start level. The engine's
effort is a level; /effort shows nothing for one level, a Thinking switch
for [off, X], a list otherwise; the status bar follows. /v1/models'
thinking_switch, efforts [] and capacity.parallel are read
(lembas/webui.ts); session/capacity.ts counts parallel slots.

Reasoning (context.md §8): an assistant message of the current turn
carries reasoning_content (provider/common.ts turnReasoning,
openai-chat.ts); step-boundary deliveries, nudges and the wrap-up are
marked turn: false and do not start a turn.

Paths (context.md §11): the <env> block names the project and, started
below the root, that directory relative to it (assemble.ts envLines);
instruction files and @ attachments are named from the root or as ~/…;
file tools and bash resolve relative paths against the project root.

The team rules (team/team.ts) and conformance runners for levels,
reasoning, paths and team; the team runtime follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:36:55 +00:00

209 lines
12 KiB
TypeScript

// The slash commands a prompt may start with anywhere, not only in the TUI: what the web UI
// offers in its composer (`available_commands_update`, `_lembas/commands`) and what a prompt sent
// over ACP that starts with `/name` turns into. The set is the shared one — custom commands
// (`.agent/commands`, global), skills (`/skill-name`), and the built-ins that need no screen of
// their own: compact, plan, undo, review, changelog, init, continue. What only the TUI can do (the
// sessions picker, settings, theme, login, quit…) is never listed and never expanded here.
//
// The TUI runs the same builders (review, changelog, init) and the same rules (a built-in name wins
// over a custom command's, a custom command's over a skill's; @files come from what was typed, never
// from a command's own text), so a command does the same thing whichever keyboard typed it.
import { existsSync } from "node:fs"
import type { App } from "../app.ts"
import { MODES, type Mode } from "../config/schema.ts"
import { git } from "../git/run.ts"
import { customCommands, expandCommand } from "../project/commands.ts"
import { fill, promptText } from "../prompt/assemble.ts"
import { skillMessage, slug, type Skill } from "../skill/index.ts"
import { CONTINUE_PROMPT } from "./engine.ts"
export type CommandKind = "custom" | "skill" | "builtin"
/** One command as ACP's `availableCommands` lists it. */
export interface CommandInfo {
name: string
description: string
input?: { hint: string }
_meta: { lembas: { kind: CommandKind } }
}
/** Every name the TUI's own commands take, aliases included (tui/commands.ts COMMANDS; a test keeps
* the two equal). A custom command or a skill of one of these names is not a command — in the TUI
* the built-in wins — so it is not one here either, even where the built-in is not offered. */
export const TUI_COMMAND_NAMES = [
"agents", "continue", "help", "model", "effort", "mode", "plan", "plans", "prototype", "review", "changelog", "init", "release", "tasks", "decisions", "decide",
"jobs", "sessions", "resume", "remote", "delete", "new", "clear", "copy", "undo", "redo", "diff", "checkpoint", "checkpoints", "branch", "commit", "context",
"compact", "memory", "skills", "skill", "personality", "voice", "speak", "mcp", "login", "logout", "settings", "usage", "kb", "theme", "skin", "terminal",
"icons", "trust", "reload", "restart", "quit", "exit", "q",
] as const
/** The built-ins that work without a screen of their own. Descriptions as the TUI's (a test). */
export const SHARED_BUILTINS: { name: string; description: string; hint?: string }[] = [
{ name: "compact", description: "summarise the conversation to free context" },
{ name: "plan", description: "plan mode — read, investigate, write a plan for approval; with a task, start on it", hint: "task" },
{ name: "undo", description: "take back the last prompt: its file changes and its conversation" },
{ name: "review", description: "a read-only review of this session's changes (or since the last commit, or against a branch)", hint: "head | <branch>" },
{ name: "changelog", description: "update CHANGELOG.md's Unreleased section from the commits since the last tag", hint: "since" },
{ name: "init", description: "make or update the project's AGENTS.md from what the repository says", hint: "what to focus on" },
{ name: "continue", description: "carry on — after esc stopped it, or when it should keep going (ctrl+g)" },
{ name: "agents", description: "subagents: list them, make one, or hand one a task", hint: "name" },
]
/** `/agents [name]`: the subagents the task tool offers, or one of them — the text both the TUI and
* a client over ACP show. Making one (`new`) and handing one a task need a screen: the TUI's. */
export function agentsText(app: App, arg: string): string {
const list = app.agents
const describe = (a: (typeof list)[number]) =>
[
`${a.name} (${a.source}) — ${a.description}`,
` model ${a.model ?? "the session's"} · mode ${a.mode ?? "the session's"} · tools ${a.tools?.join(", ") ?? "all a subagent may have"}${a.isolation ? " · own worktree" : ""}`,
].join("\n")
const name = arg.trim().split(/\s+/)[0]
if (name) {
const one = list.find((a) => a.name === name)
return one ? describe(one) : `no agent "${name}" — ${list.map((a) => a.name).join(", ")}`
}
return [...list.map(describe), "", 'The model hands work to one through its task tool when that helps; you can ask it to ("use the explore subagent to…").'].join("\n")
}
const reserved = new Set<string>(TUI_COMMAND_NAMES)
/** Where the commands come from: a trusted project's `.agent` (or none), and the skills. */
export interface CommandSources {
projectDir?: string
skills: () => Skill[]
}
export function sourcesOf(app: App): CommandSources {
return { projectDir: app.trusted && existsSync(app.project.dir) ? app.project.dir : undefined, skills: () => app.skills() }
}
/** The shared set, built-ins first, then custom commands, then skills. */
export function commandSet(src: CommandSources): CommandInfo[] {
const out: CommandInfo[] = SHARED_BUILTINS.map((b) => ({ name: b.name, description: b.description, ...(b.hint ? { input: { hint: b.hint } } : {}), _meta: { lembas: { kind: "builtin" as const } } }))
const custom = customCommands(src.projectDir).filter((c) => !reserved.has(c.name))
for (const c of custom) out.push({ name: c.name, description: c.description, ...(c.body.includes("$") ? { input: { hint: "arguments" } } : {}), _meta: { lembas: { kind: "custom" } } })
const taken = new Set(custom.map((c) => c.name))
for (const s of src.skills()) {
const name = slug(s.name)
if (reserved.has(name) || taken.has(name)) continue
taken.add(name)
out.push({ name, description: s.description.slice(0, 200), input: { hint: "instruction" }, _meta: { lembas: { kind: "skill" } } })
}
return out
}
/** What a prompt starting with a command turns into. */
export type Expanded =
/** A prompt to send: `prompt` to the model, `shown` in the transcript, @files read from
* `attachFrom` only; a model and mode for this one prompt. */
| { kind: "prompt"; name: string; prompt: string; shown: string; attachFrom: string; model?: string; mode?: Mode }
/** A built-in that is not a prompt (compact, undo; plan without a task). */
| { kind: "action"; name: "compact" | "undo" | "plan"; arg: string }
/** Nothing to send, and why (a review with no changes). */
| { kind: "message"; name: string; text: string }
/** `/review [head | <branch>]`: the change, as a prompt for a read-only review — or why there is none. */
export function reviewPrompt(app: App, arg: string): { prompt: string } | { message: string } {
const root = app.project.gitRoot
let diff: string | undefined
let scope: string
if (arg && arg !== "head") {
if (!root) return { message: "not a git repository" }
const r = git(root, ["diff", "--no-color", "--no-ext-diff", "--no-textconv", `${arg}...HEAD`])
if (r.code !== 0) return { message: `git diff ${arg}...HEAD: ${r.err}` }
diff = r.out
scope = `The change is this branch against ${arg}.`
} else if (arg === "head") {
if (!root) return { message: "not a git repository" }
diff = git(root, ["diff", "--no-color", "--no-ext-diff", "--no-textconv", "HEAD"]).out
scope = "The change is everything not yet committed."
} else {
diff = app.turns.diff()
scope = "The change is what this session did."
}
if (!diff?.trim()) return { message: "nothing to review — no changes" + (arg ? "" : " in this session (/review head: since the last commit)") }
const clipped = diff.length > 60_000 ? diff.slice(0, 60_000) + "\n[… diff cut at 60 000 characters]" : diff
return { prompt: fill(promptText("tasks/review.md"), { scope, diff: clipped }) }
}
/** `/changelog [since]`: the commits since the last tag, as a prompt to update the Unreleased section. */
export function changelogPrompt(app: App, arg: string): { prompt: string } | { message: string } {
const git0 = app.project.gitRoot
if (!git0) return { message: "not a git repository" }
// The project may be one directory of a larger repository: its commits, its files.
const root = app.project.root
const only = root === git0 ? [] : ["--", "."]
const since = arg.trim() || git(root, ["describe", "--tags", "--abbrev=0"]).out
const range = since ? `${since}..HEAD` : "HEAD"
const log = git(root, ["log", "--no-merges", "--format=- %s%n%w(0,2,2)%b", range, ...only]).out.replace(/\n{2,}/g, "\n").trim()
if (!log) return { message: since ? `no commits since ${since}` : "no commits yet" }
const stat = git(root, ["diff", "--stat", since ? since : git(root, ["rev-list", "--max-parents=0", "HEAD"]).out.split("\n")[0]!, "HEAD", ...only]).out
return {
prompt: fill(promptText("tasks/changelog.md"), {
scope: since ? `Unreleased since ${since}.` : "Nothing has been released yet.",
log: log.slice(0, 20_000),
stat: stat.slice(-6_000),
}),
}
}
/** `/init [focus]`: make or update AGENTS.md. */
export function initPrompt(app: App, arg: string): string {
return fill(promptText("tasks/init.md"), {
focus: arg.trim() ? `What the user wants it to focus on: ${arg.trim()}` : "",
}).replace(/\n{3,}/g, "\n\n")
}
/** A prompt's text as a command of the shared set, or undefined when it is not one — then it goes
* to the model as it is, `/` and all (a path, a regex, a command this machine does not have). */
export function expandSlash(app: App, text: string): Expanded | undefined {
const t = text.trim()
const m = /^\/([a-z0-9_:-]+)(?:\s+([\s\S]*))?$/i.exec(t)
if (!m) return undefined
const name = m[1]!.toLowerCase()
const arg = (m[2] ?? "").trim()
switch (name) {
case "compact":
case "undo":
return { kind: "action", name, arg }
case "plan":
return arg ? { kind: "prompt", name, prompt: arg, shown: arg, attachFrom: arg, mode: "plan" } : { kind: "action", name, arg }
case "continue":
return { kind: "prompt", name, prompt: CONTINUE_PROMPT, shown: "↻ continue", attachFrom: "" }
case "agents":
// List and describe anywhere; `new` and `<name> <task>` are the TUI's own.
if (/^(new)\b/.test(arg) || arg.trim().split(/\s+/).length > 1) return { kind: "message", name, text: "/agents here lists and describes the subagents; making one or handing one a task is done at the terminal." }
return { kind: "message", name, text: agentsText(app, arg) }
case "init":
// The prompt is ours; only what was typed after the name may attach files.
return { kind: "prompt", name, prompt: initPrompt(app, arg), shown: t, attachFrom: arg }
case "review": {
const r = reviewPrompt(app, arg)
// The diff is somebody else's text: an @path in it must not attach a file.
return "message" in r ? { kind: "message", name, text: r.message } : { kind: "prompt", name, prompt: r.prompt, shown: t, attachFrom: "", mode: "plan" }
}
case "changelog": {
const r = changelogPrompt(app, arg)
return "message" in r ? { kind: "message", name, text: r.message } : { kind: "prompt", name, prompt: r.prompt, shown: t, attachFrom: "" }
}
}
if (reserved.has(name)) return undefined
const src = sourcesOf(app)
const cc = customCommands(src.projectDir).find((c) => c.name === name)
if (cc) {
// A project's command may make the mode stricter for its prompt, never looser; your own may.
const own = cc.mode && (MODES as readonly string[]).includes(cc.mode) ? (cc.mode as Mode) : undefined
const mode = own && cc.source === "project" ? stricter(own, app.engine.mode) : own
return { kind: "prompt", name, prompt: expandCommand(cc, arg, app.project.root, app.engine.o.perm.hardline), shown: t, attachFrom: arg, model: cc.model, ...(mode ? { mode } : {}) }
}
const sk = src.skills().find((s) => slug(s.name) === name)
if (sk) return { kind: "prompt", name, prompt: skillMessage(sk, arg), shown: t, attachFrom: arg }
return undefined
}
const RANK: Record<Mode, number> = { auto: 0, edit: 1, manual: 2, plan: 3 }
function stricter(a: Mode, b: Mode): Mode {
return RANK[a] > RANK[b] ? a : b
}