refactor: table-drive the built-in slash-command dispatch
handleSubmit's ~200-line if/return chain becomes a declarative
{test, run}[] list plus one dispatch loop. Same matching semantics
(exact vs. loose startsWith), same order (/permissions before /perm,
/model before /mode), same handler bodies — just uniform structure and
a single place the command set is registered. Handlers still close over
component state; extracting them into an independently testable module
is a larger follow-up.
Also drops a stale comment claiming Ollama can't report context length
for ":cloud" models — /api/show now returns it for every cloud model
tested (glm-5.2, qwen3.5, kimi-k2, minimax, gemma4).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+105
-90
@@ -305,12 +305,11 @@ export function App({
|
||||
});
|
||||
}, [push]);
|
||||
|
||||
// Ollama/LM Studio don't report a usable context length for every model (notably cloud-routed
|
||||
// models, e.g. "*:cloud" tags, whose metadata isn't the local GGUF info /api/show expects) — when
|
||||
// that happens contextWindow silently falls back to a small hardcoded default (see
|
||||
// backend/contextWindow.ts), which makes auto-compaction trigger far more often than the model's
|
||||
// real limit would require, burning extra summarization round-trips. Surface it so the user can
|
||||
// set the real value instead of silently eating that cost every session.
|
||||
// When neither backend reports a context length (model not loaded in LM Studio, /api/show
|
||||
// unreachable, a model type Ollama has no metadata for) contextWindow silently falls back to a
|
||||
// small hardcoded default (see backend/contextWindow.ts), which makes auto-compaction trigger far
|
||||
// more often than the model's real limit would require, burning extra summarization round-trips.
|
||||
// Surface it so the user can set the real value instead of silently eating that cost every session.
|
||||
const notifyIfContextWindowGuessed = useCallback(
|
||||
(model: string, contextWindow: { value: number; isEstimate: boolean }) => {
|
||||
if (!contextWindow.isEstimate) return;
|
||||
@@ -720,21 +719,25 @@ export function App({
|
||||
|
||||
push({ kind: "user", text: trimmed });
|
||||
|
||||
if (trimmed === "/exit" || trimmed === "/quit") {
|
||||
exit();
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/help") {
|
||||
push({ kind: "help" });
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/clear") {
|
||||
// Built-in slash commands, checked in order before plugin commands / skills / @mentions get a
|
||||
// look. `test` gets the trimmed input; `run` gets it too and slices its own argument (a prefix
|
||||
// match like `/model` deliberately stays loose — `startsWith` — to match historical behavior).
|
||||
// Order matters where prefixes overlap: `/permissions` sits before `/perm`, `/model` before
|
||||
// `/mode`.
|
||||
const builtinCommands: { test: (t: string) => boolean; run: (t: string) => void | Promise<void> }[] = [
|
||||
{ test: (t) => t === "/exit" || t === "/quit", run: () => exit() },
|
||||
{ test: (t) => t === "/help", run: () => push({ kind: "help" }) },
|
||||
{
|
||||
test: (t) => t === "/clear",
|
||||
run: () => {
|
||||
resetSession(session);
|
||||
push({ kind: "notice", text: "Conversation history cleared." });
|
||||
persistCurrentSession();
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/status") {
|
||||
},
|
||||
},
|
||||
{
|
||||
test: (t) => t === "/status",
|
||||
run: () =>
|
||||
push({
|
||||
kind: "status",
|
||||
model: session.model,
|
||||
@@ -746,10 +749,11 @@ export function App({
|
||||
contextWindow: session.contextWindow,
|
||||
contextTokensIsEstimate: session.lastContextTokensIsEstimate,
|
||||
contextWindowIsEstimate: session.contextWindowIsEstimate,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/dashboard" || trimmed === "/stats") {
|
||||
}),
|
||||
},
|
||||
{
|
||||
test: (t) => t === "/dashboard" || t === "/stats",
|
||||
run: () =>
|
||||
push({
|
||||
kind: "dashboard",
|
||||
model: session.model,
|
||||
@@ -766,10 +770,11 @@ export function App({
|
||||
contextWindow: session.contextWindow,
|
||||
contextTokensIsEstimate: session.lastContextTokensIsEstimate,
|
||||
contextWindowIsEstimate: session.contextWindowIsEstimate,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/compact") {
|
||||
}),
|
||||
},
|
||||
{
|
||||
test: (t) => t === "/compact",
|
||||
run: async () => {
|
||||
setIsThinking(true);
|
||||
try {
|
||||
const summary = await compactSession(session);
|
||||
@@ -781,10 +786,12 @@ export function App({
|
||||
} finally {
|
||||
setIsThinking(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (trimmed.startsWith("/export")) {
|
||||
const rest = trimmed.slice("/export".length).trim();
|
||||
},
|
||||
},
|
||||
{
|
||||
test: (t) => t.startsWith("/export"),
|
||||
run: (t) => {
|
||||
const rest = t.slice("/export".length).trim();
|
||||
// "/export json [file]" -> JSON dump; "/export [file]" -> markdown transcript.
|
||||
let fmt: ExportFormat = "markdown";
|
||||
let arg = rest;
|
||||
@@ -793,10 +800,12 @@ export function App({
|
||||
arg = rest === "json" ? "" : rest.slice("json".length).trim();
|
||||
}
|
||||
setExportPrompt({ defaultName: arg || defaultExportFilename(fmt), format: fmt });
|
||||
return;
|
||||
}
|
||||
if (trimmed.startsWith("/import")) {
|
||||
const rest = trimmed.slice("/import".length).trim();
|
||||
},
|
||||
},
|
||||
{
|
||||
test: (t) => t.startsWith("/import"),
|
||||
run: async (t) => {
|
||||
const rest = t.slice("/import".length).trim();
|
||||
if (!rest) {
|
||||
push({ kind: "notice", text: "Usage: /import <path> [caption]", isError: true });
|
||||
return;
|
||||
@@ -810,61 +819,52 @@ export function App({
|
||||
} catch (err) {
|
||||
push({ kind: "notice", text: `Import failed: ${(err as Error).message}`, isError: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/tools") {
|
||||
push({ kind: "tools", tools: session.toolset.tools });
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/permissions") {
|
||||
push({ kind: "permissions", allowed: session.permissions.listAllowed() });
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/sessions") {
|
||||
push({ kind: "sessions", sessions: listSessions() });
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/mcp" || trimmed === "/mcp reconnect") {
|
||||
if (trimmed === "/mcp reconnect" && sessionRef.current) {
|
||||
},
|
||||
},
|
||||
{ test: (t) => t === "/tools", run: () => push({ kind: "tools", tools: session.toolset.tools }) },
|
||||
{ test: (t) => t === "/permissions", run: () => push({ kind: "permissions", allowed: session.permissions.listAllowed() }) },
|
||||
{ test: (t) => t === "/sessions", run: () => push({ kind: "sessions", sessions: listSessions() }) },
|
||||
{
|
||||
test: (t) => t === "/mcp" || t === "/mcp reconnect",
|
||||
run: async (t) => {
|
||||
if (t === "/mcp reconnect" && sessionRef.current) {
|
||||
try {
|
||||
const newMcpTools = await reconnectMcpServers(cwd);
|
||||
// Rebuild the session's toolset: keep non-MCP tools (built-ins + plugin tools already in
|
||||
// the toolset), drop the old MCP tools (namespaced `mcp__...`), and add the fresh ones.
|
||||
const kept = sessionRef.current.toolset.tools.filter((t) => !t.name.startsWith("mcp__"));
|
||||
const kept = sessionRef.current.toolset.tools.filter((tool) => !tool.name.startsWith("mcp__"));
|
||||
sessionRef.current.toolset = buildToolSet([...kept, ...newMcpTools]);
|
||||
push({ kind: "mcp", statuses: getMcpStatuses() });
|
||||
} catch (err) {
|
||||
push({ kind: "mcp", statuses: getMcpStatuses() });
|
||||
} catch {
|
||||
// fall through to just re-rendering current statuses
|
||||
}
|
||||
return;
|
||||
}
|
||||
push({ kind: "mcp", statuses: getMcpStatuses() });
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/plugins") {
|
||||
push({ kind: "plugins", plugins: getLoadedPlugins(), commandCollisions: getPluginCommandCollisions() });
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/hooks") {
|
||||
push({ kind: "hooks", config: loadMergedHooks(cwd) });
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/skills") {
|
||||
},
|
||||
},
|
||||
{
|
||||
test: (t) => t === "/plugins",
|
||||
run: () => push({ kind: "plugins", plugins: getLoadedPlugins(), commandCollisions: getPluginCommandCollisions() }),
|
||||
},
|
||||
{ test: (t) => t === "/hooks", run: () => push({ kind: "hooks", config: loadMergedHooks(cwd) }) },
|
||||
{
|
||||
test: (t) => t === "/skills",
|
||||
run: () => {
|
||||
const skills = getLoadedPlugins().flatMap((p) => p.skills);
|
||||
push({ kind: "skills", skills, collisions: findSkillCollisions(skills) });
|
||||
return;
|
||||
}
|
||||
if (trimmed.startsWith("/model")) {
|
||||
const name = trimmed.slice("/model".length).trim();
|
||||
if (!name) {
|
||||
push({ kind: "notice", text: `Current model: ${session.model}` });
|
||||
} else {
|
||||
await switchModel(name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (trimmed.startsWith("/backend")) {
|
||||
const name = trimmed.slice("/backend".length).trim();
|
||||
},
|
||||
},
|
||||
{
|
||||
test: (t) => t.startsWith("/model"),
|
||||
run: async (t) => {
|
||||
const name = t.slice("/model".length).trim();
|
||||
if (!name) push({ kind: "notice", text: `Current model: ${session.model}` });
|
||||
else await switchModel(name);
|
||||
},
|
||||
},
|
||||
{
|
||||
test: (t) => t.startsWith("/backend"),
|
||||
run: async (t) => {
|
||||
const name = t.slice("/backend".length).trim();
|
||||
if (!name) {
|
||||
push({ kind: "notice", text: `Current backend URL: ${baseURLRef.current}` });
|
||||
} else if (name !== "ollama" && name !== "lmstudio") {
|
||||
@@ -872,10 +872,12 @@ export function App({
|
||||
} else {
|
||||
await switchBackend(name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (trimmed.startsWith("/perm")) {
|
||||
const name = trimmed.slice("/perm".length).trim();
|
||||
},
|
||||
},
|
||||
{
|
||||
test: (t) => t.startsWith("/perm"),
|
||||
run: (t) => {
|
||||
const name = t.slice("/perm".length).trim();
|
||||
const validModes: Record<string, PermissionMode> = {
|
||||
default: "default",
|
||||
plan: "plan",
|
||||
@@ -902,10 +904,12 @@ export function App({
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (trimmed.startsWith("/mode")) {
|
||||
const name = trimmed.slice("/mode".length).trim();
|
||||
},
|
||||
},
|
||||
{
|
||||
test: (t) => t.startsWith("/mode"),
|
||||
run: (t) => {
|
||||
const name = t.slice("/mode".length).trim();
|
||||
if (!name) {
|
||||
push({ kind: "notice", text: `Current tool-call mode: ${session.mode}` });
|
||||
} else if (name !== "native" && name !== "fallback") {
|
||||
@@ -916,12 +920,23 @@ export function App({
|
||||
setCachedMode(baseURLRef.current, session.model, name);
|
||||
push({ kind: "notice", text: `Forced tool-call mode to "${name}" (cached for this model).` });
|
||||
persistCurrentSession();
|
||||
runHooksForEvent("ConfigChange", { sessionId: session.id, cwd }, { key: "toolMode", previous: previousMode, value: name }).catch(
|
||||
() => {},
|
||||
);
|
||||
runHooksForEvent(
|
||||
"ConfigChange",
|
||||
{ sessionId: session.id, cwd },
|
||||
{ key: "toolMode", previous: previousMode, value: name },
|
||||
).catch(() => {});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const command of builtinCommands) {
|
||||
if (command.test(trimmed)) {
|
||||
await command.run(trimmed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// UserPromptSubmit hooks see the raw text before plugin-command expansion or @mention
|
||||
// resolution — a hook can block the message outright, or inject extra context (appended
|
||||
// below wherever the message actually ends up going).
|
||||
|
||||
Reference in New Issue
Block a user