diff --git a/src/ui/ink/App.tsx b/src/ui/ink/App.tsx index 34a0d16..2b224b9 100644 --- a/src/ui/ink/App.tsx +++ b/src/ui/ink/App.tsx @@ -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 [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 }[] = [ + { 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 [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 = { + 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 = { - 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).