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:
kim
2026-09-10 16:05:45 +09:00
co-authored by Claude Sonnet 5
parent e6795effe1
commit e572fc5d76
+220 -205
View File
@@ -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,208 +719,224 @@ 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") {
resetSession(session);
push({ kind: "notice", text: "Conversation history cleared." });
persistCurrentSession();
return;
}
if (trimmed === "/status") {
push({
kind: "status",
model: session.model,
baseURL: baseURLRef.current,
mode: session.mode,
cwd,
sessionId: session.id,
contextTokens: session.lastContextTokens,
contextWindow: session.contextWindow,
contextTokensIsEstimate: session.lastContextTokensIsEstimate,
contextWindowIsEstimate: session.contextWindowIsEstimate,
});
return;
}
if (trimmed === "/dashboard" || trimmed === "/stats") {
push({
kind: "dashboard",
model: session.model,
baseURL: baseURLRef.current,
sessionId: session.id,
elapsedMs: Date.now() - new Date(session.createdAt).getTime(),
turns: session.stats.turns,
apiCalls: session.stats.apiCalls,
toolCalls: session.stats.toolCalls,
inputTokens: session.stats.inputTokens,
outputTokens: session.stats.outputTokens,
modelTimeMs: session.stats.modelTimeMs,
contextTokens: session.lastContextTokens,
contextWindow: session.contextWindow,
contextTokensIsEstimate: session.lastContextTokensIsEstimate,
contextWindowIsEstimate: session.contextWindowIsEstimate,
});
return;
}
if (trimmed === "/compact") {
setIsThinking(true);
try {
const summary = await compactSession(session);
lastCompactSummaryRef.current = summary;
push({ kind: "notice", text: "Conversation compacted to save context. (ctrl+o to see full summary)" });
persistCurrentSession();
} catch (err) {
push({ kind: "notice", text: `Compaction failed: ${(err as Error).message}`, isError: true });
} finally {
setIsThinking(false);
}
return;
}
if (trimmed.startsWith("/export")) {
const rest = trimmed.slice("/export".length).trim();
// "/export json [file]" -> JSON dump; "/export [file]" -> markdown transcript.
let fmt: ExportFormat = "markdown";
let arg = rest;
if (rest === "json" || rest.startsWith("json ")) {
fmt = "json";
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();
if (!rest) {
push({ kind: "notice", text: "Usage: /import <path> [caption]", isError: true });
// 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();
},
},
{
test: (t) => t === "/status",
run: () =>
push({
kind: "status",
model: session.model,
baseURL: baseURLRef.current,
mode: session.mode,
cwd,
sessionId: session.id,
contextTokens: session.lastContextTokens,
contextWindow: session.contextWindow,
contextTokensIsEstimate: session.lastContextTokensIsEstimate,
contextWindowIsEstimate: session.contextWindowIsEstimate,
}),
},
{
test: (t) => t === "/dashboard" || t === "/stats",
run: () =>
push({
kind: "dashboard",
model: session.model,
baseURL: baseURLRef.current,
sessionId: session.id,
elapsedMs: Date.now() - new Date(session.createdAt).getTime(),
turns: session.stats.turns,
apiCalls: session.stats.apiCalls,
toolCalls: session.stats.toolCalls,
inputTokens: session.stats.inputTokens,
outputTokens: session.stats.outputTokens,
modelTimeMs: session.stats.modelTimeMs,
contextTokens: session.lastContextTokens,
contextWindow: session.contextWindow,
contextTokensIsEstimate: session.lastContextTokensIsEstimate,
contextWindowIsEstimate: session.contextWindowIsEstimate,
}),
},
{
test: (t) => t === "/compact",
run: async () => {
setIsThinking(true);
try {
const summary = await compactSession(session);
lastCompactSummaryRef.current = summary;
push({ kind: "notice", text: "Conversation compacted to save context. (ctrl+o to see full summary)" });
persistCurrentSession();
} catch (err) {
push({ kind: "notice", text: `Compaction failed: ${(err as Error).message}`, isError: true });
} finally {
setIsThinking(false);
}
},
},
{
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;
if (rest === "json" || rest.startsWith("json ")) {
fmt = "json";
arg = rest === "json" ? "" : rest.slice("json".length).trim();
}
setExportPrompt({ defaultName: arg || defaultExportFilename(fmt), format: fmt });
},
},
{
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;
}
const spaceIdx = rest.indexOf(" ");
const filePathArg = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
const caption = spaceIdx === -1 ? "" : rest.slice(spaceIdx + 1).trim();
try {
const content = await buildImportContent(cwd, filePathArg, caption);
await submitTurn(session, content);
} catch (err) {
push({ kind: "notice", text: `Import failed: ${(err as Error).message}`, isError: true });
}
},
},
{ 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((tool) => !tool.name.startsWith("mcp__"));
sessionRef.current.toolset = buildToolSet([...kept, ...newMcpTools]);
} catch {
// fall through to just re-rendering current statuses
}
}
push({ kind: "mcp", statuses: getMcpStatuses() });
},
},
{
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) });
},
},
{
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") {
push({ kind: "notice", text: `Unknown backend "${name}". Use "ollama" or "lmstudio".`, isError: true });
} else {
await switchBackend(name);
}
},
},
{
test: (t) => t.startsWith("/perm"),
run: (t) => {
const name = t.slice("/perm".length).trim();
const validModes: Record<string, PermissionMode> = {
default: "default",
plan: "plan",
"auto-edit": "auto-edit",
"auto-accept": "auto-accept",
};
if (!name) {
// Cycle
const modes: PermissionMode[] = ["default", "plan", "auto-edit", "auto-accept"];
const currentIdx = modes.indexOf(permMode);
const nextMode = modes[(currentIdx + 1) % modes.length]!;
setPermMode(nextMode);
session.permissions.setMode(nextMode);
push({ kind: "notice", text: `Permission mode: ${PERM_MODE_LABELS[nextMode]}` });
} else if (validModes[name]) {
const newMode = validModes[name];
setPermMode(newMode);
session.permissions.setMode(newMode);
push({ kind: "notice", text: `Permission mode set to "${newMode}".` });
} else {
push({
kind: "notice",
text: `Unknown permission mode "${name}". Use "default", "plan", "auto-edit", or "auto-accept".`,
isError: true,
});
}
},
},
{
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") {
push({ kind: "notice", text: `Unknown mode "${name}". Use "native" or "fallback".`, isError: true });
} else {
const previousMode = session.mode;
setMode(session, name);
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(() => {});
}
},
},
];
for (const command of builtinCommands) {
if (command.test(trimmed)) {
await command.run(trimmed);
return;
}
const spaceIdx = rest.indexOf(" ");
const filePathArg = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
const caption = spaceIdx === -1 ? "" : rest.slice(spaceIdx + 1).trim();
try {
const content = await buildImportContent(cwd, filePathArg, caption);
await submitTurn(session, content);
} 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) {
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__"));
sessionRef.current.toolset = buildToolSet([...kept, ...newMcpTools]);
push({ kind: "mcp", statuses: getMcpStatuses() });
} catch (err) {
push({ kind: "mcp", statuses: getMcpStatuses() });
}
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") {
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();
if (!name) {
push({ kind: "notice", text: `Current backend URL: ${baseURLRef.current}` });
} else if (name !== "ollama" && name !== "lmstudio") {
push({ kind: "notice", text: `Unknown backend "${name}". Use "ollama" or "lmstudio".`, isError: true });
} else {
await switchBackend(name);
}
return;
}
if (trimmed.startsWith("/perm")) {
const name = trimmed.slice("/perm".length).trim();
const validModes: Record<string, PermissionMode> = {
default: "default",
plan: "plan",
"auto-edit": "auto-edit",
"auto-accept": "auto-accept",
};
if (!name) {
// Cycle
const modes: PermissionMode[] = ["default", "plan", "auto-edit", "auto-accept"];
const currentIdx = modes.indexOf(permMode);
const nextMode = modes[(currentIdx + 1) % modes.length]!;
setPermMode(nextMode);
session.permissions.setMode(nextMode);
push({ kind: "notice", text: `Permission mode: ${PERM_MODE_LABELS[nextMode]}` });
} else if (validModes[name]) {
const newMode = validModes[name];
setPermMode(newMode);
session.permissions.setMode(newMode);
push({ kind: "notice", text: `Permission mode set to "${newMode}".` });
} else {
push({
kind: "notice",
text: `Unknown permission mode "${name}". Use "default", "plan", "auto-edit", or "auto-accept".`,
isError: true,
});
}
return;
}
if (trimmed.startsWith("/mode")) {
const name = trimmed.slice("/mode".length).trim();
if (!name) {
push({ kind: "notice", text: `Current tool-call mode: ${session.mode}` });
} else if (name !== "native" && name !== "fallback") {
push({ kind: "notice", text: `Unknown mode "${name}". Use "native" or "fallback".`, isError: true });
} else {
const previousMode = session.mode;
setMode(session, name);
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(
() => {},
);
}
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).