Skip to content
Open
35 changes: 35 additions & 0 deletions src/cli/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ import { useActivityLabel } from "./hooks/useActivityPhase.js";
import { useAgentSession } from "./hooks/useAgentSession.js";
import { useCodeMode } from "./hooks/useCodeMode.js";
import { useEditGate } from "./hooks/useEditGate.js";
import { useExtraSlashHandlers } from "./hooks/useExtraSlashHandlers.js";
import { useHookList } from "./hooks/useHookList.js";
import { useInputRecall } from "./hooks/useInputRecall.js";
import { useLanguageReload } from "./hooks/useLanguageReload.js";
Expand Down Expand Up @@ -592,6 +593,7 @@ function AppInner({
codeMode?.rootDir,
);
const { hookList, reloadHooks } = useHookList(codeMode?.rootDir);
const extraSlash = useExtraSlashHandlers(currentRootDir);
// Session-scoped edit history + undo banner + /undo, /history, /show
// handlers. Kept in a custom hook so App.tsx only sees the small API
// it needs —append an edit, arm the banner, answer the slash
Expand Down Expand Up @@ -1111,6 +1113,33 @@ function AppInner({
[currentRootDir, loop.client, loop.log.entries, loop.model, model, onSwitchSession, session],
);

// biome-ignore lint/correctness/useExhaustiveDependencies: subagentSinkRef.current is a mutable ref intentionally read at call time.
const runSlashSubagent = useCallback(
async (skill: import("../../skills.js").Skill, task: string): Promise<string> => {
if (!tools) {
return `▲ subagent "${skill.name}" is unavailable in this session (no tool registry wired).`;
}
const result = await spawnSubagent({
client: loop.client,
parentRegistry: tools,
system: skill.body,
task,
model: skill.model,
allowedTools: skill.allowedTools,
sink: subagentSinkRef.current,
skillName: skill.name,
});
if (result.forcedSummary) {
return `▸ subagent "${skill.name}" returned a partial answer\n\n${result.output}`;
}
if (!result.success) {
return `▲ subagent "${skill.name}" failed: ${result.error ?? "unknown subagent error"}`;
}
return result.output;
},
[loop.client, tools],
);

const switchWorkspaceRoot = useCallback(
(newPath: string) => {
if (!codeMode?.reregisterTools) return { ok: false, info: t("handlers.edits.cwdCodeOnly") };
Expand Down Expand Up @@ -3029,6 +3058,7 @@ function AppInner({
codeHistory: codeMode ? codeHistory : undefined,
codeShowEdit: codeMode ? codeShowEdit : undefined,
codeRoot: codeMode ? currentRootDir : undefined,
workspaceRoot: currentRootDir,
pendingEditCount: codeMode ? pendingEdits.current.length : undefined,
memoryRoot: currentRootDir,
planMode,
Expand Down Expand Up @@ -3069,6 +3099,7 @@ function AppInner({
status: weixin.status,
},
sessionId: session,
runSlashSubagent,
getEngineeringLifecycleSnapshot: codeMode
? () => engineeringLifecycleRef.current?.snapshot() ?? null
: undefined,
Expand Down Expand Up @@ -3124,6 +3155,8 @@ function AppInner({
return added;
},
reloadHooks: () => reloadHooks(codeMode ? currentRootDir : undefined),
extraHandlers: extraSlash.handlers,
reloadExtraHandlers: () => extraSlash.reload(),
switchCwd: codeMode?.reregisterTools ? switchWorkspaceRoot : undefined,
reloadMcp: mcpRuntime
? async () => {
Expand Down Expand Up @@ -3697,6 +3730,8 @@ function AppInner({
generateCurrentSessionTitle,
switchWorkspaceRoot,
system,
runSlashSubagent,
extraSlash,
],
);

Expand Down
1 change: 1 addition & 0 deletions src/cli/ui/PlanPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
*/

import { Box, type Color, Text } from "ink";
// biome-ignore lint/style/useImportType: classic JSX transform needs React in scope
import React, { useMemo, useState } from "react";
import { t } from "../../i18n/index.js";
import type { PlanStep, StepCompletion } from "../../tools/plan.js";
Expand Down
162 changes: 162 additions & 0 deletions src/cli/ui/hooks/useExtraSlashHandlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// Combined extra slash-command handler map: .md commands > settings.json > skills/agents.

import { join } from "node:path";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { SkillStore } from "../../../skills.js";
import { setExtraSlashSpecs } from "../slash/commands.js";
import { CustomSlashRegistry } from "../slash/custom.js";
import type { SlashHandler } from "../slash/dispatch.js";
import { loadMarkdownCommands, substituteArguments } from "../slash/md-commands.js";
import type { SlashCommandSpec } from "../slash/types.js";

export interface ExtraSlashHandlers {
/** Combined handler map. Keys are slash-command names. */
handlers: Record<string, SlashHandler>;
/** Reload all sources from disk. Returns total extra handler count. */
reload: () => number;
}

function dirs(root: string | undefined, ...segments: string[]): string[] {
if (!root) return [];
return [join(root, ...segments)];
}

export function useExtraSlashHandlers(
projectRoot: string | undefined,
homeDir?: string,
): ExtraSlashHandlers {
const build = useCallback((): Record<string, SlashHandler> => {
const root = projectRoot;
const handlers: Record<string, SlashHandler> = {};

// Skill auto-registration (lowest priority)
const store = new SkillStore({ projectRoot: root, homeDir });
const skills = store.list();
const skillStoreRef = { current: store };

for (const skill of skills) {
handlers[skill.name] = (_args, _loop, _ctx) => {
const fresh = skillStoreRef.current.read(skill.name);
const resolved = fresh ?? skill;
const body = resolved.body;
const desc = resolved.description;
const extraArgs = _args.join(" ").trim();
if (resolved.runAs === "subagent") {
if (!extraArgs) {
return {
info: `skill "${resolved.name}" runs as a subagent and requires a task argument.`,
};
}
if (!_ctx.runSlashSubagent) {
return {
info: `skill "${resolved.name}" runs as a subagent, but this session cannot launch subagents from slash commands.`,
};
}
void _ctx.runSlashSubagent(resolved, extraArgs).then(
(text) => {
if (text) _ctx.postInfo?.(text);
},
(err) => {
const reason = err instanceof Error ? err.message : String(err);
_ctx.postInfo?.(`▲ subagent "${resolved.name}" failed: ${reason}`);
},
);
return {
info: `▸ running skill "${resolved.name}" — ${extraArgs}`,
};
}
const header = `# Skill: ${skill.name}${desc ? `\n> ${desc}` : ""}`;
const argsLine = extraArgs ? `\n\nArguments: ${extraArgs}` : "";
return {
info: `▸ running skill "${resolved.name}"${extraArgs ? ` — ${extraArgs}` : ""}`,
resubmit: `${header}\n\n${body}${argsLine}`,
};
};
}

// settings.json slashCommands
const registry = new CustomSlashRegistry({ projectRoot: root, homeDir });
for (const name of registry.names()) {
const cfg = registry.lookup(name);
if (!cfg) continue;
handlers[name] = (_args, _loop, _ctx) => {
return registry.execute(name, _args, cfg.command);
};
}

// .claude/commands/*.md
const claudeCommands = loadMarkdownCommands(
dirs(root, ".claude", "commands"),
".claude/commands",
);
for (const cmd of claudeCommands) {
handlers[cmd.name] = (args, _loop, _ctx) => {
const body = substituteArguments(cmd.body, args);
const argsLine = args.length > 0 ? ` — ${args.join(" ")}` : "";
return {
info: `▸ running command "${cmd.name}"${argsLine}`,
resubmit: `# Command: ${cmd.name}${cmd.description ? `\n> ${cmd.description}` : ""}\n\n${body}`,
};
};
}

// .reasonix/commands/*.md (highest priority)
const reasonixCommands = loadMarkdownCommands(
dirs(root, ".reasonix", "commands"),
".reasonix/commands",
);
for (const cmd of reasonixCommands) {
handlers[cmd.name] = (args, _loop, _ctx) => {
const body = substituteArguments(cmd.body, args);
const argsLine = args.length > 0 ? ` — ${args.join(" ")}` : "";
return {
info: `▸ running command "${cmd.name}"${argsLine}`,
resubmit: `# Command: ${cmd.name}${cmd.description ? `\n> ${cmd.description}` : ""}\n\n${body}`,
};
};
}

// Build suggestion specs from all sources
const skillSpecs: SlashCommandSpec[] = skills.map((s) => ({
cmd: s.name,
summary: s.description || s.name,
group: "extend" as const,
}));
const cmdSpecs = (specs: ReturnType<typeof loadMarkdownCommands>): SlashCommandSpec[] =>
specs.map((c) => ({
cmd: c.name,
summary: c.description || c.name,
group: "extend" as const,
argsHint: c.argumentHint,
}));
const extraSpecs: SlashCommandSpec[] = [
...skillSpecs,
...registry.specs(),
...cmdSpecs(claudeCommands),
...cmdSpecs(reasonixCommands),
];
setExtraSlashSpecs(extraSpecs);

return handlers;
}, [homeDir, projectRoot]);

const handlersRef = useRef<Record<string, SlashHandler>>(build());
useEffect(() => {
handlersRef.current = build();
}, [build]);

const reload = useCallback((): number => {
handlersRef.current = build();
return Object.keys(handlersRef.current).length;
}, [build]);

return useMemo(
() => ({
get handlers() {
return handlersRef.current;
},
reload,
}),
[reload],
);
}
1 change: 1 addition & 0 deletions src/cli/ui/slash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export {
detectSlashArgContext,
orderSlashCommandsByGroup,
parseSlash,
setExtraSlashSpecs,
suggestSlashCommands,
} from "./slash/commands.js";
export { handleSlash } from "./slash/dispatch.js";
Expand Down
56 changes: 48 additions & 8 deletions src/cli/ui/slash/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,18 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
argsHint: "[reload]",
summary: "list active hooks (settings.json under .reasonix/) · reload re-reads from disk",
},
{
cmd: "slash",
group: "advanced",
argsHint: "[list|reload]",
summary: "list custom slash commands (skills + settings.json) · reload re-reads from disk",
},
{
cmd: "agents",
group: "extend",
argsHint: "[list|show <name>|new <name>|run <name> [args]]",
summary: "list / inspect agents (.reasonix/agents/ + .claude/agents/)",
},
{
cmd: "permissions",
group: "advanced",
Expand Down Expand Up @@ -428,13 +440,35 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
{ cmd: "exit", group: "advanced", summary: "quit the TUI", aliases: ["quit", "q"] },
];

/** Extra slash-command specs injected by the TUI at startup
* (skill auto-registration + custom commands from settings.json).
* Checked by `suggestSlashCommands` alongside the built-in SLASH_COMMANDS. */
let _extraSlashSpecs: readonly SlashCommandSpec[] = [];

/** Replace the extra command specs. Returns the new count. Called by App.tsx at
* startup and on `/slash reload`. Pass [] to clear. */
export function setExtraSlashSpecs(specs: readonly SlashCommandSpec[]): number {
_extraSlashSpecs = Object.freeze([...specs]);
_cachedAliasMap = null; // invalidate cache
return _extraSlashSpecs.length;
}

function allSlashCommands(): readonly SlashCommandSpec[] {
if (_extraSlashSpecs.length === 0) return SLASH_COMMANDS;
// Dedup: built-in commands take priority; extra specs with same cmd name are ignored.
const builtinNames = new Set(SLASH_COMMANDS.map((c) => c.cmd));
const uniqueExtras = _extraSlashSpecs.filter((c) => !builtinNames.has(c.cmd));
return [...SLASH_COMMANDS, ...uniqueExtras];
}

export function suggestSlashCommands(
prefix: string,
codeMode = false,
counts?: Readonly<Record<string, number>>,
): SlashCommandSpec[] {
const p = prefix.toLowerCase();
const matches = SLASH_COMMANDS.filter((c) => {
const all = allSlashCommands();
const matches = all.filter((c) => {
// Empty prefix = browsing the menu — show the full release command surface except
// advanced rows, which remain collapsed behind the footer hint.
if (p === "") return c.group !== "advanced";
Expand All @@ -453,23 +487,29 @@ export function suggestSlashCommands(
}

export function countAdvancedCommands(codeMode: boolean): number {
return SLASH_COMMANDS.filter(
return allSlashCommands().filter(
(c) => c.group === "advanced" && (c.contextual !== "code" || codeMode),
).length;
}

/** alias → canonical cmd map, derived from SLASH_COMMANDS at module init. */
const ALIAS_TO_CMD: Readonly<Record<string, string>> = (() => {
/** alias → canonical cmd map, derived from ALL available commands (built-in + extra).
* Cached; invalidated when `setExtraSlashSpecs` is called. */
let _cachedAliasMap: Record<string, string> | null = null;

function getAliasMap(): Record<string, string> {
if (_cachedAliasMap) return _cachedAliasMap;
const m: Record<string, string> = {};
for (const spec of SLASH_COMMANDS) {
for (const spec of allSlashCommands()) {
if (!spec.aliases) continue;
for (const a of spec.aliases) m[a] = spec.cmd;
}
_cachedAliasMap = m;
return m;
})();
}

export function resolveSlashAlias(name: string): string {
return ALIAS_TO_CMD[name] ?? name;
const m = getAliasMap();
return m[name] ?? name;
}

/** Picker fires only when arg tail has no internal whitespace; past that it's a usage hint. */
Expand All @@ -478,7 +518,7 @@ export function detectSlashArgContext(input: string, codeMode = false): SlashArg
if (!m) return null;
const cmdName = resolveSlashAlias(m[1]!.toLowerCase());
const tail = m[2] ?? "";
const spec = SLASH_COMMANDS.find(
const spec = allSlashCommands().find(
(s) => s.cmd === cmdName && (s.contextual !== "code" || codeMode),
);
if (!spec) return null;
Expand Down
Loading