Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/cli/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ if (args.length > 0) {
const commands: SlashCommand[] = await loadAvailableCommands(skillsDirs);

render(
<AgentLoopProvider agent={agent}>
<AgentLoopProvider agent={agent} commands={commands}>
<App commands={commands} supportProjectWideAllow />
</AgentLoopProvider>,
{ patchConsole: false },
Expand Down
63 changes: 63 additions & 0 deletions src/cli/tui/__tests__/command-registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, it } from "bun:test";

import { BUILTIN_COMMANDS, formatHelp, resolveBuiltinCommand, type SlashCommand } from "../command-registry";

describe("resolveBuiltinCommand", () => {
it("resolves a bare builtin", () => {
expect(resolveBuiltinCommand("/clear")).toEqual({ name: "clear", args: "" });
expect(resolveBuiltinCommand("/exit")).toEqual({ name: "exit", args: "" });
expect(resolveBuiltinCommand("/help")).toEqual({ name: "help", args: "" });
});

it("captures trailing args after a builtin", () => {
expect(resolveBuiltinCommand("/help clear")).toEqual({ name: "help", args: "clear" });
expect(resolveBuiltinCommand("/help skill-creator")).toEqual({
name: "help",
args: "skill-creator",
});
});

it("treats input with no leading slash the same way", () => {
expect(resolveBuiltinCommand("clear")).toEqual({ name: "clear", args: "" });
});

it("returns null for unknown commands and empty input", () => {
expect(resolveBuiltinCommand("/nope")).toBeNull();
expect(resolveBuiltinCommand("")).toBeNull();
expect(resolveBuiltinCommand(" ")).toBeNull();
});
});

describe("formatHelp", () => {
const commands: SlashCommand[] = [
...BUILTIN_COMMANDS,
{ name: "skill-creator", description: "Create new skills", type: "skill" },
];

it("lists builtins and skills when called with no target", () => {
const text = formatHelp(commands);
expect(text).toContain("Available slash commands");
expect(text).toContain("/clear");
expect(text).toContain("/help");
expect(text).toContain("/skill-creator");
expect(text).toContain("Create new skills");
});

it("renders details for a single command", () => {
const text = formatHelp(commands, "clear");
expect(text).toContain("/clear");
expect(text).toContain("Built-in command");
expect(text).toContain("Clear the current conversation history");
});

it("tolerates a leading slash and case in target", () => {
const text = formatHelp(commands, "/CLEAR");
expect(text).toContain("/clear");
});

it("returns an error message for unknown targets", () => {
const text = formatHelp(commands, "nope");
expect(text).toContain("Unknown command");
expect(text).toContain("/nope");
});
});
67 changes: 63 additions & 4 deletions src/cli/tui/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,24 @@ export const BUILTIN_COMMANDS: SlashCommand[] = [
description: "Exit the TUI session",
type: "builtin",
},
{
name: "help",
description: "List available slash commands, or show details for one (`/help <name>`)",
type: "builtin",
},
{
name: "quit",
description: "Exit the TUI session",
type: "builtin",
},
];

/** Parsed builtin invocation: command name plus any trailing argument string. */
export interface BuiltinInvocation {
name: SlashCommand["name"];
args: string;
}

export async function loadAvailableCommands(skillsDirs?: string[]): Promise<SlashCommand[]> {
const skills = await listSkills(skillsDirs);
const skillCommands = skills.map(toSkillCommand).sort((left, right) => left.name.localeCompare(right.name));
Expand Down Expand Up @@ -69,12 +80,60 @@ export function getHighlightedCommandName(text: string, commands: SlashCommand[]
return commands.some((command) => command.name.toLowerCase() === commandName) ? commandToken : null;
}

export function resolveBuiltinCommand(text: string): SlashCommand["name"] | null {
export function resolveBuiltinCommand(text: string): BuiltinInvocation | null {
const trimmed = text.trim();
if (!trimmed || /\s/.test(trimmed)) return null;
if (!trimmed) return null;

const match = trimmed.match(/^\/?([^\s]+)(?:\s+([\s\S]*))?$/);
if (!match) return null;
const token = match[1];
if (!token) return null;

const normalized = normalizeCommandName(token);
const builtin = BUILTIN_COMMANDS.find((command) => command.name === normalized);
if (!builtin) return null;

return { name: builtin.name, args: (match[2] ?? "").trim() };
}

/**
* Renders a help string for slash commands. With no `target`, lists all
* commands grouped by type. With a `target`, prints the matched command's
* details, or an error message if not found.
*/
export function formatHelp(commands: SlashCommand[], target?: string): string {
if (target) {
const normalized = normalizeCommandName(target);
const match = commands.find((c) => c.name.toLowerCase() === normalized);
if (!match) {
return `Unknown command: \`/${target}\`. Run \`/help\` to see available commands.`;
}
const kind = match.type === "builtin" ? "Built-in command" : "Skill";
return `**/${match.name}** — _${kind}_\n\n${match.description}`;
}

const builtins = commands.filter((c) => c.type === "builtin");
const skills = commands.filter((c) => c.type === "skill");

const lines: string[] = ["**Available slash commands**", ""];

if (builtins.length > 0) {
lines.push("_Built-in_");
for (const c of builtins) {
lines.push(`- \`/${c.name}\` — ${c.description}`);
}
}

if (skills.length > 0) {
if (builtins.length > 0) lines.push("");
lines.push("_Skills_");
for (const c of skills) {
lines.push(`- \`/${c.name}\` — ${c.description}`);
}
}

const normalized = normalizeCommandName(trimmed);
return BUILTIN_COMMANDS.find((command) => command.name === normalized)?.name ?? null;
lines.push("", "Run `/help <name>` for details on a single command.");
return lines.join("\n");
}

export function buildPromptSubmission(text: string, commands: SlashCommand[]): PromptSubmission {
Expand Down
38 changes: 31 additions & 7 deletions src/cli/tui/hooks/use-agent-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import type { ReactNode } from "react";
import type { Agent } from "@/agent";
import type { AssistantMessage, NonSystemMessage, UserMessage } from "@/foundation";

import type { PromptSubmission } from "../command-registry";
import { resolveBuiltinCommand } from "../command-registry";
import type { PromptSubmission, SlashCommand } from "../command-registry";
import { formatHelp, resolveBuiltinCommand } from "../command-registry";

type AgentLoopState = {
agent: Agent;
Expand All @@ -19,7 +19,15 @@ type AgentLoopState = {

const AgentLoopContext = createContext<AgentLoopState | null>(null);

export function AgentLoopProvider({ agent, children }: { agent: Agent; children: ReactNode }) {
export function AgentLoopProvider({
agent,
commands = [],
children,
}: {
agent: Agent;
commands?: SlashCommand[];
children: ReactNode;
}) {
const [streaming, setStreaming] = useState(false);
const [messages, setMessages] = useState<NonSystemMessage[]>([]);

Expand Down Expand Up @@ -74,23 +82,39 @@ export function AgentLoopProvider({ agent, children }: { agent: Agent; children:
const onSubmit = useCallback(
async (submission: PromptSubmission) => {
const { text, requestedSkillName } = submission;
const builtinCommand = resolveBuiltinCommand(text);
const invocation = resolveBuiltinCommand(text);

if (builtinCommand === "exit" || builtinCommand === "quit") {
if (invocation?.name === "exit" || invocation?.name === "quit") {
process.exit(0);
return;
}

if (streamingRef.current) return;

if (builtinCommand === "clear") {
if (invocation?.name === "clear") {
agent.clearMessages();
flushPendingMessages();
setMessages([]);
clearTerminal();
return;
}

if (invocation?.name === "help") {
flushPendingMessages();
const userMessage: UserMessage = { role: "user", content: [{ type: "text", text }] };
const helpMessage: AssistantMessage = {
role: "assistant",
content: [
{
type: "text",
text: formatHelp(commands, invocation.args || undefined),
},
],
};
setMessages((prev) => [...prev, userMessage, helpMessage]);
return;
}

setStreaming(true);

try {
Expand All @@ -116,7 +140,7 @@ export function AgentLoopProvider({ agent, children }: { agent: Agent; children:
setStreaming(false);
}
},
[agent, enqueueMessage, flushPendingMessages],
[agent, commands, enqueueMessage, flushPendingMessages],
);

const value = useMemo(
Expand Down
Loading