diff --git a/src/agent/protocol.ts b/src/agent/protocol.ts index 655871a..c247137 100644 --- a/src/agent/protocol.ts +++ b/src/agent/protocol.ts @@ -13,7 +13,9 @@ export type AgentToolName = | "read_many" | "replace_in_file" | "stat_path" - | "pwd"; + | "pwd" + | "browser_fetch" + | "mcp_call"; export type AgentToolCall = { tool: AgentToolName; @@ -82,6 +84,8 @@ Available tools: - copy_file: {"tool":"copy_file","from":"src","to":"dest"} - move_path: {"tool":"move_path","from":"src","to":"dest"} - delete_file: {"tool":"delete_file","path":"path/to/file"} +- browser_fetch: {"tool":"browser_fetch","url":"https://example.com"} +- mcp_call: {"tool":"mcp_call","name":"server.tool","arguments":{...}} Rules: - Prefer inspecting files before editing. diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 5bb1965..b0aba48 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -12,13 +12,30 @@ import { } from "node:fs/promises"; import { dirname, isAbsolute, join, relative, resolve } from "node:path"; import type { AgentToolCall } from "./protocol.js"; +import type { BrowserEngine } from "../browser/index.js"; +import type { MCPServerManager } from "../mcp/index.js"; export interface AgentToolResult { ok: boolean; output: string; } -export async function runAgentTool(call: AgentToolCall, cwd = process.cwd()): Promise { +/** + * Optional services injected at runtime so the agent can reach MCP / browser + * without importing them at module load. Keeping this optional preserves the + * existing test suite that calls `runAgentTool` with no services. + */ +export interface AgentToolServices { + browser?: BrowserEngine; + mcp?: MCPServerManager; + signal?: AbortSignal; +} + +export async function runAgentTool( + call: AgentToolCall, + cwd: string = process.cwd(), + services: AgentToolServices = {}, +): Promise { const sandbox = resolve(cwd); try { switch (call.tool) { @@ -105,6 +122,33 @@ export async function runAgentTool(call: AgentToolCall, cwd = process.cwd()): Pr ), }; } + case "browser_fetch": { + if (!services.browser) { + return { ok: false, output: "browser engine not available" }; + } + const url = requiredString(call, "url"); + const page = await services.browser.fetch(url, { signal: services.signal }); + const head = page.title ? `# ${page.title}\n\n` : ""; + const body = page.text; + const note = page.truncated ? "\n\n…[truncated]" : ""; + return { + ok: true, + output: `${head}URL: ${page.finalUrl}\nStatus: ${page.status}\nBytes: ${page.bytes}\n\n${body}${note}`, + }; + } + case "mcp_call": { + if (!services.mcp) { + return { ok: false, output: "mcp manager not available" }; + } + const name = requiredString(call, "name"); + const args = call.arguments ?? {}; + const decision = services.mcp.decide(name); + if (decision?.decision === "deny") { + return { ok: false, output: `mcp tool denied by policy: ${name}` }; + } + const result = await services.mcp.call(name, args, services.signal); + return result; + } default: return { ok: false, output: `unknown tool: ${String(call.tool)}` }; } diff --git a/src/app.tsx b/src/app.tsx index 6c1c0bc..0c7173f 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -21,16 +21,30 @@ import { Banner } from "./ui/Banner.js"; import { Spinner } from "./ui/Spinner.js"; import { StatusBar } from "./ui/StatusBar.js"; import { MessageBubble, type BubbleRole } from "./ui/Message.js"; -import { Prompt } from "./ui/Prompt.js"; +import { PromptV2 } from "./tui/PromptV2.js"; +import { DebugPanel } from "./tui/DebugPanel.js"; +import { openHistory, type InputHistory } from "./lib/inputHistory.js"; +import { clipLines } from "./lib/transcriptVirtual.js"; +import { explainError, formatFriendlyError } from "./lib/errorCatalog.js"; import { COMMANDS, findCommand, parseSlash } from "./commands/index.js"; import { getProviderOrThrow } from "./providers/index.js"; +import { AgentStateMachine, type AgentSnapshot } from "./core/AgentStateMachine.js"; +import { detectCapabilities } from "./platform/TerminalCapabilities.js"; +import { LogoAnimator } from "./tui/LogoAnimator.js"; +import { loadPolicy, type ResolvedPolicy } from "./sandbox/index.js"; +import { MCPServerManager, loadMcpConfig } from "./mcp/index.js"; +import { createTextEngine } from "./browser/index.js"; +import type { McpActions, SandboxActions, UiMode } from "./types.js"; +import { existsSync } from "node:fs"; +import { defaultMcpPath } from "./mcp/index.js"; +import { defaultPolicyPath } from "./sandbox/index.js"; import { loadConfig, saveConfig, resolveApiKey, type FastcodeConfig, } from "./config/store.js"; -import { getTheme } from "./config/themes.js"; +import { getTheme, pickThemeForCaps, DEFAULT_THEME } from "./config/themes.js"; import { createStartupTranscript } from "./lib/startup.js"; import { shouldUseLiveStreamingUi, shouldUseStableTerminalUi } from "./lib/uiMode.js"; import { buildPluginsPrompt } from "./plugins/manager.js"; @@ -50,6 +64,75 @@ interface Props { initialPrompt?: string; } +function buildMcpActions( + ref: React.MutableRefObject, +): McpActions { + return { + statusReport(): string { + const m = ref.current; + if (!m) return "MCP is not started (no `.fastcode/mcp.json` found)."; + const list = m.list(); + if (list.length === 0) return "No MCP servers configured."; + return list + .map( + (s) => + `- **${s.name}** \u2014 ${s.status} \u00b7 ${s.toolCount} tool(s) \u00b7 perm=${s.permission}` + + (s.lastError ? `\n error: ${s.lastError}` : ""), + ) + .join("\n"); + }, + toolsReport(server?: string): string { + const m = ref.current; + if (!m) return "MCP is not running."; + const entries = m.registry.list(); + const filtered = server ? entries.filter((e) => e.server === server) : entries; + if (filtered.length === 0) return "No tools available."; + return filtered + .map((e) => `- \`${e.server}.${e.spec.name}\`${e.spec.description ? ` \u2014 ${e.spec.description}` : ""}`) + .join("\n"); + }, + async restart(server: string): Promise { + const m = ref.current; + if (!m) return "MCP is not running."; + try { + await m.restart(server); + return `Restarted ${server}.`; + } catch (err) { + return `Failed to restart ${server}: ${(err as Error).message}`; + } + }, + setPermission(server, permission): string { + const m = ref.current; + if (!m) return "MCP is not running."; + m.setPermission(server, permission); + return `Permission for ${server} set to ${permission}.`; + }, + }; +} + +function buildSandboxActions( + ref: React.MutableRefObject, + caps: ReturnType, +): SandboxActions { + return { + statusReport(): string { + const p = ref.current; + if (!p) return "Sandbox policy not loaded."; + const lines = [ + `**Root**: \`${p.pathGuard.getRoot()}\``, + `**Shell**: ${p.shellEnabled ? "enabled" : "disabled"}`, + `**Egress allow** (${p.egressAllow.length}): ${p.egressAllow.slice(0, 8).join(", ") || "none"}`, + `**Egress deny by default**: ${p.egressDenyDefault ? "yes" : "no"}`, + `**Auto-approve tools**: ${[...p.riskAutoApprove].join(", ")}`, + `**Always confirm tools**: ${[...p.riskAlwaysConfirm].join(", ")}`, + `**Policy file**: ${defaultPolicyPath()}`, + `**Terminal**: ${caps.os}/${caps.shell} \u00b7 ${caps.size.columns}x${caps.size.rows} \u00b7 colorDepth=${caps.colorDepth}`, + ]; + return lines.join("\n"); + }, + }; +} + export const App: React.FC = ({ initialPrompt }) => { const { exit } = useApp(); const { stdout } = useStdout(); @@ -82,11 +165,66 @@ export const App: React.FC = ({ initialPrompt }) => { const ctrlCRef = useRef(0); const initialPromptSubmittedRef = useRef(false); - const theme = getTheme(config.theme); + // v0.2 — agent FSM, terminal capabilities, sandbox policy, MCP manager. + const fsmRef = useRef(null); + if (!fsmRef.current) fsmRef.current = new AgentStateMachine(); + const [agentSnap, setAgentSnap] = useState(() => + fsmRef.current!.get(), + ); + const capsRef = useRef(detectCapabilities()); + const caps = capsRef.current; + const policyRef = useRef(null); + if (!policyRef.current) policyRef.current = loadPolicy({ workspace: process.cwd() }); + const mcpRef = useRef(null); + const browserRef = useRef(createTextEngine()); + const historyRef = useRef(null); + if (!historyRef.current) historyRef.current = openHistory(); + const [uiMode, setUiMode] = useState( + caps.forceNoAnim ? "noanim" : caps.forceStableUi ? "compact" : "normal", + ); + + useEffect(() => { + const fsm = fsmRef.current; + if (!fsm) return; + return fsm.subscribe(setAgentSnap); + }, []); + + useEffect(() => { + // Initialize MCP only when a config file exists. Avoids spawning processes + // for users who haven't opted into MCP yet. + const path = defaultMcpPath(); + if (!existsSync(path)) return; + const cfg = loadMcpConfig({ workspace: process.cwd() }); + if (Object.keys(cfg.servers).length === 0) return; + const mgr = new MCPServerManager(cfg); + mcpRef.current = mgr; + void mgr.startAll(); + return () => { + void mgr.stopAll(); + mcpRef.current = null; + }; + }, []); + + // If the user is still on the stock default and the terminal can't render + // it well (no-color, Termux, dumb), auto-upgrade to a safer variant. + const effectiveThemeId = + config.theme === DEFAULT_THEME + ? pickThemeForCaps({ + forceNoColor: caps.forceNoColor, + os: caps.os, + colorDepth: caps.colorDepth, + }) + : config.theme; + const theme = getTheme(effectiveThemeId); const stableUi = useMemo(() => shouldUseStableTerminalUi(), []); const liveStreamingUi = useMemo(() => shouldUseLiveStreamingUi(), []); const columns = stdout?.columns ?? process.stdout.columns ?? 80; - const showBanner = transcript.length <= startup.transcript.length && !busy && columns >= 88; + const showBanner = + transcript.length <= startup.transcript.length && + !busy && + columns >= 88 && + uiMode !== "compact" && + uiMode !== "focus"; // Persist config whenever it changes. useEffect(() => { @@ -131,6 +269,17 @@ export const App: React.FC = ({ initialPrompt }) => { ]); }, []); + const pushBlock = useCallback( + (role: BubbleRole, text: string, markdown = role !== "command" && role !== "diff") => { + idRef.current += 1; + setTranscript((prev) => [ + ...prev, + { id: idRef.current, role, content: text, markdown }, + ]); + }, + [], + ); + const actions = useMemo( () => ({ setProvider: (id: string) => setConfig((c) => ({ ...c, providerId: id })), @@ -170,8 +319,11 @@ export const App: React.FC = ({ initialPrompt }) => { attachImage: (image: ChatImage) => setPendingImages((imgs) => [...imgs, image]), clearAttachedImages: () => setPendingImages([]), + setUiMode: (mode: UiMode) => setUiMode(mode), + mcp: buildMcpActions(mcpRef), + sandbox: buildSandboxActions(policyRef, caps), }), - [pushSystem], + [caps, pushSystem], ); const runSlash = useCallback( @@ -236,6 +388,7 @@ export const App: React.FC = ({ initialPrompt }) => { const text = raw.trim(); if (!text || busy) return; setInput(""); + historyRef.current?.push(text); if (text.startsWith("/")) { await runSlash(text); @@ -303,6 +456,7 @@ export const App: React.FC = ({ initialPrompt }) => { const ctrl = new AbortController(); abortRef.current = ctrl; setBusy(true); + fsmRef.current?.send({ type: "user_submit" }); try { let workingHistory = nextHistory; @@ -347,10 +501,47 @@ export const App: React.FC = ({ initialPrompt }) => { const results = []; for (const call of calls) { setAgentActivity(`Coding: ${call.tool}`); - pushSystem(`Running tool: \`${call.tool}\`\n\n\`\`\`json\n${JSON.stringify(call, null, 2)}\n\`\`\``); - const result = await runAgentTool(call, process.cwd()); + fsmRef.current?.send({ type: "tool_start", detail: call.tool }); + pushBlock( + "tool", + `\`${call.tool}\`\n\n\`\`\`json\n${JSON.stringify(call, null, 2)}\n\`\`\``, + ); + const result = await runAgentTool(call, process.cwd(), { + browser: browserRef.current, + mcp: mcpRef.current ?? undefined, + signal: ctrl.signal, + }); + if (result.ok) { + fsmRef.current?.send({ type: "tool_done" }); + } else { + fsmRef.current?.send({ + type: "tool_failed", + code: "TOOL_FAILED", + message: result.output.slice(0, 120), + }); + } results.push({ call, ...result }); - pushSystem(`Tool ${result.ok ? "completed" : "failed"}: \`${call.tool}\`\n\n\`\`\`\n${result.output}\n\`\`\``); + const isDiff = + result.ok && + call.tool === "shell" && + /^diff --git |^@@ /m.test(result.output); + const clipped = clipLines(result.output, 200); + if (isDiff) { + pushBlock("diff", clipped); + } else if (result.ok) { + pushBlock( + "success", + `\`${call.tool}\`\n\n\`\`\`\n${clipped}\n\`\`\``, + ); + } else { + const friendly = explainError(new Error(result.output), { + op: call.tool, + }); + pushBlock( + "error", + `${formatFriendlyError(friendly)}\n\n\`\`\`\n${clipped}\n\`\`\``, + ); + } } const toolMessage = formatToolResults(results); @@ -375,15 +566,17 @@ export const App: React.FC = ({ initialPrompt }) => { ]); } catch (err) { if ((err as Error).name === "AbortError") { - pushSystem("Cancelled."); + pushBlock("warn", "Cancelled."); } else { - pushSystem(`Error: ${(err as Error).message}`); + const friendly = explainError(err, { op: "chat" }); + pushBlock("error", formatFriendlyError(friendly)); } } finally { setStreamingId(null); setStreamingText(""); setAgentActivity(null); setBusy(false); + fsmRef.current?.send({ type: "reset" }); abortRef.current = null; } }, @@ -437,6 +630,37 @@ export const App: React.FC = ({ initialPrompt }) => { ) : null} + {uiMode === "debug" ? ( + + ) : null} + {uiMode !== "focus" ? ( + + + + ) : null} {busy ? ( = ({ initialPrompt }) => { ) : null} - - - - {COMMANDS.length} commands · {config.enabledSkills.length} skill(s) active - + {uiMode !== "focus" ? ( + + ) : null} + {uiMode === "normal" || uiMode === "debug" ? ( + + {COMMANDS.length} commands · {config.enabledSkills.length} skill(s) active · /palette + + ) : null} ); diff --git a/src/browser/headless.ts b/src/browser/headless.ts new file mode 100644 index 0000000..beccee3 --- /dev/null +++ b/src/browser/headless.ts @@ -0,0 +1,136 @@ +/** + * Headless browser engine — Playwright-backed. + * + * `playwright-core` is an optional peer dep. If it isn't installed at runtime + * we throw a friendly error and let the caller fall back to the text engine. + * + * We intentionally use `playwright-core` (not `playwright`) so installs don't + * pull a 300 MB browser bundle. Users opt in by installing it and the engine + * binary explicitly: + * npm i playwright-core + * npx playwright install chromium # optional; reuse system Chrome via channel + */ +import type { + BrowserEngine, + BrowserFetchOptions, + BrowserFetchResult, + BrowserPolicyHook, +} from "./types.js"; + +export class HeadlessEngineUnavailableError extends Error { + readonly code = "HEADLESS_UNAVAILABLE"; + constructor(public readonly reason: string) { + super(`headless browser engine unavailable: ${reason}`); + } +} + +export interface HeadlessOptions { + policy?: BrowserPolicyHook; + /** Optional path to a Chrome/Chromium binary. Defaults to bundled. */ + executablePath?: string; + /** Channel name for playwright (e.g. "chrome", "msedge"). */ + channel?: string; + /** Default user agent. */ + userAgent?: string; + /** Max body chars to extract. */ + maxChars?: number; +} + +interface PlaywrightModule { + chromium: { + launch(opts?: unknown): Promise; + }; +} + +let cachedModule: PlaywrightModule | null = null; +let cachedFailure: Error | null = null; + +async function tryImportPlaywright(): Promise { + if (cachedModule) return cachedModule; + if (cachedFailure) throw cachedFailure; + try { + // Dynamic import keeps the package optional. The string template prevents + // bundlers from trying to resolve it at build time. + const moduleName = "playwright-core"; + const mod = (await import(/* @vite-ignore */ moduleName)) as PlaywrightModule; + cachedModule = mod; + return mod; + } catch (err) { + const reason = (err as Error).message; + cachedFailure = new HeadlessEngineUnavailableError( + `couldn't import playwright-core: ${reason}. Run \`npm i playwright-core\` and \`npx playwright install chromium\` to enable.`, + ); + throw cachedFailure; + } +} + +export async function isHeadlessAvailable(): Promise { + try { + await tryImportPlaywright(); + return true; + } catch { + return false; + } +} + +export async function createHeadlessEngine( + opts: HeadlessOptions = {}, +): Promise { + const pw = await tryImportPlaywright(); + const maxChars = opts.maxChars ?? 100_000; + + return { + async fetch(rawUrl: string, fetchOpts: BrowserFetchOptions = {}): Promise { + const url = new URL(rawUrl); + if (opts.policy) opts.policy.assertHostAllowed(url.hostname); + const launchArgs: Record = { headless: true }; + if (opts.executablePath) launchArgs.executablePath = opts.executablePath; + if (opts.channel) launchArgs.channel = opts.channel; + const browser = (await pw.chromium.launch(launchArgs)) as { + newContext(o?: unknown): Promise; + close(): Promise; + }; + try { + const ctx = (await browser.newContext({ + userAgent: opts.userAgent ?? "FastCodeHeadless/0.2", + })) as { + newPage(): Promise; + close(): Promise; + }; + const page = (await ctx.newPage()) as { + goto(u: string, o?: unknown): Promise<{ status(): number; headers(): Record } | null>; + title(): Promise; + content(): Promise; + evaluate(fn: string | ((arg?: unknown) => T)): Promise; + url(): string; + }; + const resp = await page.goto(url.toString(), { + waitUntil: "domcontentloaded", + timeout: fetchOpts.timeoutMs ?? 30_000, + }); + const status = resp?.status() ?? 0; + const title = await page.title(); + // Pull innerText so we get rendered text (not raw HTML). + // We pass the body of the function as a string to avoid needing DOM types + // here; Playwright serializes and runs it inside the page context. + const text = await page.evaluate( + "() => (document.body && document.body.innerText) || ''", + ); + const finalUrl = page.url(); + const truncated = text.length > maxChars; + const clipped = truncated ? text.slice(0, maxChars) : text; + return { + url: rawUrl, + finalUrl, + status, + title, + text: clipped, + bytes: Buffer.byteLength(clipped, "utf8"), + truncated, + }; + } finally { + await browser.close(); + } + }, + }; +} diff --git a/src/browser/index.ts b/src/browser/index.ts new file mode 100644 index 0000000..1ecb303 --- /dev/null +++ b/src/browser/index.ts @@ -0,0 +1,73 @@ +/** + * Public browser-tool API. + * + * v0.2 ships two engines: + * • text — undici + cheerio (always available, no extra deps) + * • headless — playwright-core (opt-in, installed separately) + * + * `loadBrowserEngine({ prefer: "headless" })` falls back to text when + * playwright-core isn't installed, so user code can ask for the better engine + * without writing an availability check. + */ + +import type { BrowserEngine, BrowserPolicyHook } from "./types.js"; +import { createTextEngine } from "./text.js"; +import { + createHeadlessEngine, + isHeadlessAvailable, + HeadlessEngineUnavailableError, +} from "./headless.js"; + +export type EnginePreference = "auto" | "text" | "headless"; + +export interface LoadEngineOptions { + prefer?: EnginePreference; + policy?: BrowserPolicyHook; + /** Pass true to require the requested engine — no silent fallback. */ + strict?: boolean; +} + +export interface LoadedEngine { + engine: BrowserEngine; + kind: "text" | "headless"; +} + +export async function loadBrowserEngine( + opts: LoadEngineOptions = {}, +): Promise { + const result = await loadBrowserEngineDetailed(opts); + return result.engine; +} + +export async function loadBrowserEngineDetailed( + opts: LoadEngineOptions = {}, +): Promise { + const want = opts.prefer ?? "auto"; + if (want === "headless") { + const available = await isHeadlessAvailable(); + if (!available && opts.strict) { + throw new HeadlessEngineUnavailableError("headless requested in strict mode"); + } + if (!available) { + return { engine: createTextEngine(opts.policy), kind: "text" }; + } + return { engine: await createHeadlessEngine({ policy: opts.policy }), kind: "headless" }; + } + if (want === "auto" && (await isHeadlessAvailable())) { + try { + const engine = await createHeadlessEngine({ policy: opts.policy }); + return { engine, kind: "headless" }; + } catch { + // Fall through to text. + } + } + return { engine: createTextEngine(opts.policy), kind: "text" }; +} + +export { createTextEngine } from "./text.js"; +export { + createHeadlessEngine, + isHeadlessAvailable, + HeadlessEngineUnavailableError, +} from "./headless.js"; +export type { BrowserEngine, BrowserFetchResult, BrowserPolicyHook } from "./types.js"; diff --git a/src/browser/text.ts b/src/browser/text.ts new file mode 100644 index 0000000..3c69788 --- /dev/null +++ b/src/browser/text.ts @@ -0,0 +1,42 @@ +/** + * Text-only browser engine. + * + * Built on top of `lib/web.ts:fetchPage` (already used by /web). We only add + * an optional NetworkGuard hook so the sandbox can deny non-allowlisted hosts. + */ + +import { fetchPage } from "../lib/web.js"; +import type { BrowserEngine, BrowserFetchResult, BrowserPolicyHook } from "./types.js"; + +export function createTextEngine(policy?: BrowserPolicyHook): BrowserEngine { + return { + async fetch(rawUrl, opts = {}): Promise { + if (policy) { + const host = extractHost(rawUrl); + if (host) policy.assertHostAllowed(host); + } + const page = await fetchPage(rawUrl, { + signal: opts.signal, + maxChars: opts.maxChars, + }); + return { + url: page.url, + finalUrl: page.finalUrl, + status: 200, + title: page.title, + text: page.text, + bytes: page.bytes, + truncated: page.truncated, + }; + }, + }; +} + +function extractHost(input: string): string | null { + try { + const u = new URL(input.includes("://") ? input : `https://${input}`); + return u.hostname; + } catch { + return null; + } +} diff --git a/src/browser/types.ts b/src/browser/types.ts new file mode 100644 index 0000000..dc12d4a --- /dev/null +++ b/src/browser/types.ts @@ -0,0 +1,30 @@ +/** + * Browser tool types. The shape is intentionally small in v0.2 — we ship a + * text-only engine first and add a headless engine later via lazy `await import`. + */ + +export interface BrowserFetchResult { + url: string; + finalUrl: string; + status: number; + title?: string; + text: string; + bytes: number; + truncated: boolean; +} + +export interface BrowserFetchOptions { + signal?: AbortSignal; + maxChars?: number; + timeoutMs?: number; +} + +export interface BrowserEngine { + /** Fetch a URL, parse HTML, return cleaned text body. */ + fetch(url: string, opts?: BrowserFetchOptions): Promise; +} + +export interface BrowserPolicyHook { + /** Called before every fetch. Throws to deny. */ + assertHostAllowed(host: string): void; +} diff --git a/src/commands/index.ts b/src/commands/index.ts index ef0233a..479bc18 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -29,6 +29,11 @@ import { whoamiCommand } from "./whoami.js"; import { webCommand } from "./web.js"; import { shCommand } from "./sh.js"; import { imgCommand } from "./img.js"; +import { mcpCommand } from "./mcp.js"; +import { sandboxCommand } from "./sandbox.js"; +import { modeCommand } from "./mode.js"; +import { searchCommand } from "./search.js"; +import { paletteCommand } from "./palette.js"; export const COMMANDS: SlashCommand[] = [ helpCommand, @@ -53,6 +58,11 @@ export const COMMANDS: SlashCommand[] = [ webCommand, shCommand, imgCommand, + mcpCommand, + sandboxCommand, + modeCommand, + searchCommand, + paletteCommand, ]; export function findCommand(name: string): SlashCommand | undefined { diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts new file mode 100644 index 0000000..efd1097 --- /dev/null +++ b/src/commands/mcp.ts @@ -0,0 +1,42 @@ +/** + * /mcp slash command — view + manage MCP servers and tools. + */ + +import type { SlashCommand, CommandResult } from "../types.js"; + +export const mcpCommand: SlashCommand = { + name: "mcp", + description: "Manage MCP servers and tools", + async run({ args, actions }): Promise { + if (!actions.mcp) { + return { + message: + "MCP is not configured. Create a `.fastcode/mcp.json` and restart.\n" + + "See the FastCode v0.2 blueprint for the file format.", + }; + } + const sub = args[0]?.toLowerCase(); + switch (sub) { + case undefined: + case "status": + return { message: actions.mcp.statusReport() }; + case "tools": + return { message: actions.mcp.toolsReport(args[1]) }; + case "restart": + if (!args[1]) return { message: "Usage: /mcp restart " }; + return { message: await actions.mcp.restart(args[1]) }; + case "permission": + if (!args[1] || !args[2]) { + return { message: "Usage: /mcp permission auto|confirm|deny" }; + } + return { + message: actions.mcp.setPermission( + args[1], + args[2] as "auto" | "confirm" | "deny", + ), + }; + default: + return { message: `Unknown /mcp subcommand: ${sub}` }; + } + }, +}; diff --git a/src/commands/mode.ts b/src/commands/mode.ts new file mode 100644 index 0000000..aff1451 --- /dev/null +++ b/src/commands/mode.ts @@ -0,0 +1,32 @@ +/** + * /mode — switch display mode at runtime. + */ + +import type { SlashCommand, CommandResult } from "../types.js"; + +const MODES = ["normal", "compact", "focus", "debug", "noanim"] as const; +type Mode = (typeof MODES)[number]; + +export const modeCommand: SlashCommand = { + name: "mode", + description: "Switch display mode (normal, compact, focus, debug, noanim)", + async run({ args, actions }): Promise { + if (!actions.setUiMode) { + return { message: "Display modes are unavailable in this build." }; + } + const requested = (args[0] ?? "").toLowerCase(); + if (!requested) { + return { + message: + "Usage: /mode \n" + + "Available modes: " + + MODES.join(", "), + }; + } + if (!MODES.includes(requested as Mode)) { + return { message: `Unknown mode: ${requested}. Try one of: ${MODES.join(", ")}` }; + } + actions.setUiMode(requested as Mode); + return { message: `Mode set to **${requested}**.` }; + }, +}; diff --git a/src/commands/palette.ts b/src/commands/palette.ts new file mode 100644 index 0000000..522e8ea --- /dev/null +++ b/src/commands/palette.ts @@ -0,0 +1,48 @@ +/** + * /palette — pretty-print every visible slash command with its description. + * + * This is the textual fallback for a future Ctrl+P overlay; for now it gives + * users a single-screen snapshot of everything they can do. + */ +import type { SlashCommand, CommandResult } from "../types.js"; +import { COMMANDS } from "./index.js"; + +export const paletteCommand: SlashCommand = { + name: "palette", + aliases: ["commands", "cmds"], + description: "Show every available slash command with its description.", + async run(): Promise { + const visible = COMMANDS.filter((c) => !c.hidden); + const groups: Record = { + "Session": [], + "Editing": [], + "Provider": [], + "Sandbox + MCP": [], + "Misc": [], + }; + for (const c of visible) { + groups[bucketFor(c.name)]!.push(c); + } + const lines: string[] = ["**Command palette**", ""]; + for (const [label, list] of Object.entries(groups)) { + if (list.length === 0) continue; + lines.push(`__${label}__`); + for (const cmd of list) { + const aliases = cmd.aliases?.length ? ` _(aliases: ${cmd.aliases.join(", ")})_` : ""; + lines.push(` • \`/${cmd.name}\`${aliases} — ${cmd.description}`); + } + lines.push(""); + } + return { message: lines.join("\n") }; + }, +}; + +function bucketFor(name: string): "Session" | "Editing" | "Provider" | "Sandbox + MCP" | "Misc" { + if (["clear", "reset", "exit", "cost", "config", "system", "whoami", "about", "search", "palette", "help"].includes(name)) + return "Session"; + if (["theme", "mode", "agent", "skills", "plugin", "tools", "img", "cwd"].includes(name)) + return "Editing"; + if (["model", "provider", "login", "logout"].includes(name)) return "Provider"; + if (["mcp", "sandbox", "web", "sh"].includes(name)) return "Sandbox + MCP"; + return "Misc"; +} diff --git a/src/commands/sandbox.ts b/src/commands/sandbox.ts new file mode 100644 index 0000000..e5fa29a --- /dev/null +++ b/src/commands/sandbox.ts @@ -0,0 +1,23 @@ +/** + * /sandbox — show the effective sandbox policy for the current project. + */ + +import type { SlashCommand, CommandResult } from "../types.js"; + +export const sandboxCommand: SlashCommand = { + name: "sandbox", + description: "Show effective sandbox policy (path / shell / network)", + async run({ args, actions }): Promise { + if (!actions.sandbox) { + return { message: "Sandbox is unavailable in this build." }; + } + const sub = args[0]?.toLowerCase(); + switch (sub) { + case undefined: + case "status": + return { message: actions.sandbox.statusReport() }; + default: + return { message: `Unknown /sandbox subcommand: ${sub}` }; + } + }, +}; diff --git a/src/commands/search.ts b/src/commands/search.ts new file mode 100644 index 0000000..a6f8de0 --- /dev/null +++ b/src/commands/search.ts @@ -0,0 +1,57 @@ +/** + * /search — fuzzy-grep the prompt history (~/.fastcode/history). + * + * Reads the JSONL history directly so the command works even when the running + * App hasn't gathered any new entries yet (fresh restart). + */ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { SlashCommand, CommandResult } from "../types.js"; + +const DEFAULT_PATH = join(homedir(), ".fastcode", "history"); +const MAX_HITS = 10; + +export const searchCommand: SlashCommand = { + name: "search", + aliases: ["history-search", "find"], + description: "Search recent prompt history for a query.", + async run({ args }): Promise { + const query = args.join(" ").trim().toLowerCase(); + if (!query) { + return { message: "Usage: /search " }; + } + if (!existsSync(DEFAULT_PATH)) { + return { message: "No prompt history yet." }; + } + const raw = readFileSync(DEFAULT_PATH, "utf8"); + const hits: Array<{ ts: number; text: string }> = []; + for (const line of raw.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const obj = JSON.parse(trimmed) as { ts?: number; text?: string }; + if (typeof obj.text === "string" && obj.text.toLowerCase().includes(query)) { + hits.push({ ts: obj.ts ?? 0, text: obj.text }); + } + } catch { + // ignore corrupt + } + } + if (hits.length === 0) { + return { message: `No history match for \`${query}\`.` }; + } + const recent = hits.slice(-MAX_HITS).reverse(); + const lines = recent.map((h, i) => { + const when = h.ts ? new Date(h.ts).toISOString().slice(0, 19).replace("T", " ") : "?"; + const oneLine = h.text.replace(/\n/g, " ⏎ ").slice(0, 200); + return `**${i + 1}.** \`${when}\`\n${oneLine}`; + }); + return { + message: + `Found ${hits.length} match${hits.length === 1 ? "" : "es"} for \`${query}\`. ` + + `Showing the ${recent.length} most recent:\n\n` + + lines.join("\n\n"), + }; + }, +}; diff --git a/src/config/themes.ts b/src/config/themes.ts index 0b622c7..52b0a80 100644 --- a/src/config/themes.ts +++ b/src/config/themes.ts @@ -70,6 +70,81 @@ export const THEMES: Record = { error: "red", success: "white", }, + cyber: { + id: "cyber", + label: "Cyber (neon)", + banner: ["#ff00aa", "#00ffff", "#7d12ff"], + user: "magentaBright", + assistant: "cyanBright", + system: "gray", + border: "magenta", + accent: "cyanBright", + muted: "gray", + error: "redBright", + success: "greenBright", + warn: "yellowBright", + tool: "magentaBright", + command: "cyanBright", + diffAdd: "greenBright", + diffDel: "redBright", + }, + nocolor: { + id: "nocolor", + label: "No-color (NO_COLOR / pipes / dumb terminals)", + banner: ["#ffffff", "#ffffff", "#ffffff"], + user: "white", + assistant: "white", + system: "white", + border: "white", + accent: "white", + muted: "white", + error: "white", + success: "white", + warn: "white", + tool: "white", + command: "white", + diffAdd: "white", + diffDel: "white", + noColor: true, + }, + hicontrast: { + id: "hicontrast", + label: "High contrast (a11y)", + banner: ["#ffffff", "#ffff00", "#ffffff"], + user: "yellowBright", + assistant: "whiteBright", + system: "white", + border: "yellowBright", + accent: "yellowBright", + muted: "white", + error: "redBright", + success: "greenBright", + warn: "yellowBright", + tool: "yellowBright", + command: "whiteBright", + diffAdd: "greenBright", + diffDel: "redBright", + }, + termux: { + id: "termux", + label: "Termux-safe (Android)", + banner: ["#88ff88", "#88ccff", "#ffaa88"], + // Avoid `*Bright` variants on Termux \u2014 some terminals render them as + // truecolor approximations and lose contrast on AMOLED. + user: "cyan", + assistant: "white", + system: "gray", + border: "cyan", + accent: "cyan", + muted: "gray", + error: "red", + success: "green", + warn: "yellow", + tool: "magenta", + command: "cyan", + diffAdd: "green", + diffDel: "red", + }, }; export const DEFAULT_THEME = "fastcode"; @@ -78,3 +153,17 @@ export function getTheme(id: string | undefined): Theme { if (id && THEMES[id]) return THEMES[id]; return THEMES[DEFAULT_THEME]!; } + +/** + * Pick the most appropriate theme for a given terminal capability profile. + * Used as a fallback when the user hasn't picked one explicitly. + */ +export function pickThemeForCaps(caps: { + forceNoColor: boolean; + os: string; + colorDepth: number; +}): string { + if (caps.forceNoColor || caps.colorDepth === 0) return "nocolor"; + if (caps.os === "termux") return "termux"; + return DEFAULT_THEME; +} diff --git a/src/core/AgentStateMachine.ts b/src/core/AgentStateMachine.ts new file mode 100644 index 0000000..931ef33 --- /dev/null +++ b/src/core/AgentStateMachine.ts @@ -0,0 +1,170 @@ +/** + * AgentStateMachine — single source of truth for the agent lifecycle. + * + * Today app.tsx juggles `busy`, `streamingId`, `streamingText`, `agentActivity`. + * That's four state slots that drift apart on edge cases (e.g. busy=true while + * streamingId=null produces a phantom spinner). This FSM collapses the legal + * universe to 6 states and makes every transition explicit. + * + * The FSM is **pure** — no React, no timers, no async. The TUI subscribes to + * snapshots and renders accordingly. The chat loop calls `send(event)` whenever + * something happens (provider stream start, tool call, confirmation, etc.). + */ + +export type AgentState = + | "idle" + | "thinking" + | "executing" + | "waiting" + | "success" + | "error"; + +export interface PendingConfirm { + tool: string; + risk: "low" | "med" | "high"; + preview: string; +} + +export interface AgentSnapshot { + state: AgentState; + /** User-safe label, e.g. "Reading project…" — never chain-of-thought. */ + label: string; + /** Optional secondary detail (active tool, command, server). */ + detail?: string; + /** Wall-clock when the *current state* was entered. */ + startedAt: number; + /** Convenience: ms since startedAt at the moment `get()` was called. */ + durationMs: number; + /** When `state === "error"`. */ + errorCode?: string; + /** When `state === "waiting"` and a confirmation is needed. */ + needsConfirm?: PendingConfirm; +} + +export type AgentEvent = + | { type: "user_submit" } + | { type: "stream_start" } + | { type: "stream_chunk" } + | { type: "stream_end" } + | { type: "tool_plan"; detail?: string } + | { type: "tool_start"; detail: string } + | { type: "tool_done" } + | { type: "tool_failed"; code: string; message: string } + | { type: "confirm_request"; payload: PendingConfirm } + | { type: "confirm_approve" } + | { type: "confirm_reject" } + | { type: "reset" }; + +export type AgentListener = (snapshot: AgentSnapshot) => void; + +const NOW = (): number => Date.now(); + +export class AgentStateMachine { + private snap: AgentSnapshot; + private listeners = new Set(); + + constructor() { + this.snap = { + state: "idle", + label: "Ready", + startedAt: NOW(), + durationMs: 0, + }; + } + + /** Returns a fresh snapshot with `durationMs` filled in. */ + get(): AgentSnapshot { + return { ...this.snap, durationMs: NOW() - this.snap.startedAt }; + } + + subscribe(fn: AgentListener): () => void { + this.listeners.add(fn); + return () => { + this.listeners.delete(fn); + }; + } + + send(ev: AgentEvent): AgentSnapshot { + const next = this.transition(this.snap, ev); + if (next) { + const stateChanged = next.state !== this.snap.state; + this.snap = { + ...next, + startedAt: stateChanged ? NOW() : this.snap.startedAt, + durationMs: 0, + }; + const out = this.get(); + for (const fn of [...this.listeners]) { + try { + fn(out); + } catch { + // Listener errors are isolated. + } + } + } + return this.get(); + } + + private transition(s: AgentSnapshot, ev: AgentEvent): AgentSnapshot | null { + switch (ev.type) { + case "user_submit": + return { ...s, state: "thinking", label: "Thinking…", detail: undefined, errorCode: undefined }; + case "stream_start": + return { ...s, state: "thinking", label: "Thinking…", detail: undefined }; + case "stream_chunk": + // No state change; this is just a heartbeat for renderers that want it. + return null; + case "stream_end": + return null; + case "tool_plan": + return { + ...s, + state: "executing", + label: ev.detail ?? "Running…", + detail: ev.detail, + }; + case "tool_start": + return { ...s, state: "executing", label: ev.detail, detail: ev.detail }; + case "tool_done": + return { ...s, state: "thinking", label: "Thinking…", detail: undefined }; + case "tool_failed": + return { + ...s, + state: "error", + label: ev.message || "Tool failed", + errorCode: ev.code, + detail: undefined, + }; + case "confirm_request": + return { + ...s, + state: "waiting", + label: "Waiting for confirmation", + needsConfirm: ev.payload, + }; + case "confirm_approve": + return { + ...s, + state: "executing", + label: "Approved — running…", + needsConfirm: undefined, + }; + case "confirm_reject": + return { + ...s, + state: "thinking", + label: "Rejected — replanning…", + needsConfirm: undefined, + }; + case "reset": + return { + ...s, + state: "idle", + label: "Ready", + detail: undefined, + errorCode: undefined, + needsConfirm: undefined, + }; + } + } +} diff --git a/src/core/EventBus.ts b/src/core/EventBus.ts new file mode 100644 index 0000000..e482779 --- /dev/null +++ b/src/core/EventBus.ts @@ -0,0 +1,79 @@ +/** + * Tiny typed pub/sub bus. + * + * The bus replaces the ad-hoc setState fan-out in app.tsx so the AgentStateMachine, + * LogoAnimator, MCP manager, and sandbox can all coordinate without holding refs to + * each other. + * + * Design notes: + * - Listeners run synchronously in registration order. + * - A throwing listener is logged and isolated; it does not prevent other listeners. + * - Each `on()` returns an unsubscribe function so callers don't have to track refs. + */ + +import type { AgentSnapshot } from "./AgentStateMachine.js"; + +export interface EventMap { + "app:start": void; + "app:ready": void; + "chat:user-message": { text: string }; + "chat:assistant-stream": { id: number; delta: string }; + "chat:assistant-done": { id: number; text: string }; + "ai:state-change": AgentSnapshot; + "tool:start": { id: number; name: string; args: unknown }; + "tool:output": { id: number; chunk: string }; + "tool:success": { id: number; output: string; durationMs: number }; + "tool:error": { id: number; code: string; message: string }; + "mcp:connected": { name: string; toolCount: number }; + "mcp:disconnected": { name: string; reason: string }; + "mcp:tool-called": { server: string; tool: string }; + "terminal:resize": { columns: number; rows: number }; + "input:submit": { text: string }; + "input:cancel": void; + "session:saved": { path: string }; +} + +type Listener = (payload: EventMap[E]) => void; +type AnyListener = (payload: never) => void; + +export class EventBus { + private map = new Map>(); + + on(event: E, fn: Listener): () => void { + let set = this.map.get(event); + if (!set) { + set = new Set(); + this.map.set(event, set); + } + set.add(fn as AnyListener); + return () => { + set?.delete(fn as AnyListener); + }; + } + + emit(event: E, payload: EventMap[E]): void { + const set = this.map.get(event); + if (!set) return; + // Copy so a listener that unsubscribes during dispatch doesn't shift the set. + for (const fn of [...set]) { + try { + (fn as Listener)(payload); + } catch (err) { + // Surface to stderr but never let one listener break the others. + process.stderr.write(`[bus:${String(event)}] listener threw: ${(err as Error).message}\n`); + } + } + } + + /** Test helper — number of listeners on a topic. */ + listenerCount(event: E): number { + return this.map.get(event)?.size ?? 0; + } + + /** Drop every listener. Used in tests. */ + clear(): void { + this.map.clear(); + } +} + +export const bus = new EventBus(); diff --git a/src/lib/errorCatalog.ts b/src/lib/errorCatalog.ts new file mode 100644 index 0000000..5e07468 --- /dev/null +++ b/src/lib/errorCatalog.ts @@ -0,0 +1,156 @@ +/** + * Friendly error catalog. Translates common low-level errors (ENOENT, EACCES, + * EPIPE, undici timeouts, MCP transport, sandbox denials, model API failures) + * into a structured form with a short code, a human label, and a fix hint. + * + * Used by the chat layer to render an `error` bubble that's actionable + * instead of a raw stack trace. + */ + +export interface FriendlyError { + code: string; + title: string; + detail: string; + hint?: string; +} + +export interface ErrorContext { + /** What action was being attempted, e.g. "shell", "file_read", "mcp_call". */ + op?: string; + /** Optional path the error referenced. */ + path?: string; + /** Optional URL involved. */ + url?: string; +} + +const CATALOG: Array<{ + match: (msg: string, err: unknown) => boolean; + build: (msg: string, ctx: ErrorContext) => FriendlyError; +}> = [ + { + match: (msg) => /ENOENT/.test(msg), + build: (msg, ctx) => ({ + code: "FS_NOT_FOUND", + title: "File not found", + detail: msg, + hint: ctx.path + ? `Check that \`${ctx.path}\` exists and is reachable from the workspace.` + : "Check the path; relative paths resolve from the workspace root.", + }), + }, + { + match: (msg) => /EACCES|permission denied/i.test(msg), + build: (msg, ctx) => ({ + code: "FS_DENIED", + title: "Permission denied", + detail: msg, + hint: ctx.path + ? `\`${ctx.path}\` isn't writable by this user. Try \`chmod\` or run from an owner-writable workspace.` + : "The current user lacks permission. Check ownership / chmod.", + }), + }, + { + match: (msg) => /EPIPE/.test(msg), + build: (msg) => ({ + code: "PIPE_BROKEN", + title: "Broken pipe", + detail: msg, + hint: "The downstream process exited early. Re-run the command in isolation to see its real output.", + }), + }, + { + match: (msg) => /ETIMEDOUT|timeout/i.test(msg), + build: (msg, ctx) => ({ + code: "NET_TIMEOUT", + title: "Network timeout", + detail: msg, + hint: ctx.url + ? `Couldn't reach \`${new URL(ctx.url).host}\` within the timeout. Check connectivity and retry.` + : "Connection timed out. Check network reachability and retry.", + }), + }, + { + match: (msg) => /ENOTFOUND|getaddrinfo/i.test(msg), + build: (msg, ctx) => ({ + code: "NET_DNS", + title: "DNS lookup failed", + detail: msg, + hint: ctx.url + ? `Couldn't resolve \`${new URL(ctx.url).host}\`. Verify the host name and DNS.` + : "DNS resolution failed. Verify the hostname.", + }), + }, + { + match: (msg) => /SANDBOX_/.test(msg), + build: (msg) => ({ + code: "SANDBOX_DENIED", + title: "Blocked by sandbox policy", + detail: msg, + hint: "Edit `.fastcode/policy.json` to allow this path/host/shell, or run /sandbox status to inspect the current rules.", + }), + }, + { + match: (msg) => /MCP|JSON-RPC|jsonrpc/i.test(msg), + build: (msg) => ({ + code: "MCP_TRANSPORT", + title: "MCP server transport error", + detail: msg, + hint: "Run /mcp status to see which server failed; /mcp restart to retry.", + }), + }, + { + match: (msg) => /AbortError|aborted/i.test(msg), + build: (msg) => ({ + code: "ABORTED", + title: "Cancelled", + detail: msg, + }), + }, + { + match: (msg) => /401|403|Unauthorized|Forbidden/.test(msg), + build: (msg) => ({ + code: "AUTH_FAILED", + title: "Authentication failed", + detail: msg, + hint: "Check your provider API key (run /model to switch, or set the environment variable).", + }), + }, + { + match: (msg) => /429|rate limit/i.test(msg), + build: (msg) => ({ + code: "RATE_LIMITED", + title: "Rate limited", + detail: msg, + hint: "Wait a few seconds, then retry. Consider switching to a different model with /model.", + }), + }, + { + match: (msg) => /5\d\d/.test(msg) && /api|model|provider/i.test(msg), + build: (msg) => ({ + code: "PROVIDER_5XX", + title: "Provider returned an error", + detail: msg, + hint: "The model provider had a transient failure. Retry; if it persists, switch model with /model.", + }), + }, +]; + +export function explainError(err: unknown, ctx: ErrorContext = {}): FriendlyError { + const msg = err instanceof Error ? err.message : String(err); + for (const entry of CATALOG) { + if (entry.match(msg, err)) return entry.build(msg, ctx); + } + return { + code: "UNKNOWN", + title: ctx.op ? `${ctx.op} failed` : "Operation failed", + detail: msg, + }; +} + +export function formatFriendlyError(err: FriendlyError): string { + const lines = [`**${err.title}** (\`${err.code}\`)`, "", err.detail]; + if (err.hint) { + lines.push("", `_${err.hint}_`); + } + return lines.join("\n"); +} diff --git a/src/lib/inputHistory.ts b/src/lib/inputHistory.ts new file mode 100644 index 0000000..d2efcd3 --- /dev/null +++ b/src/lib/inputHistory.ts @@ -0,0 +1,128 @@ +/** + * Persistent input history for the prompt. + * + * Stores the user's submitted prompts (newest-last) in `~/.fastcode/history`, + * one JSON per line so the file stays human-readable and easy to truncate. + * + * The store is intentionally tiny — no indexing, no search server. Up/Down + * arrow recall is plenty for the current TUI; full /search will come later. + */ +import { existsSync, mkdirSync, readFileSync, appendFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { homedir } from "node:os"; + +export interface HistoryEntry { + ts: number; + text: string; +} + +export interface InputHistory { + /** Newest-last list of recent entries. */ + list(): HistoryEntry[]; + /** Append `text` and persist asynchronously. */ + push(text: string): void; + /** Look up an entry by reverse index (0 = most recent). Returns null if out of range. */ + recall(reverseIndex: number): HistoryEntry | null; + /** Total number of in-memory entries. */ + size(): number; +} + +const DEFAULT_MAX = 500; + +export interface OpenOpts { + path?: string; + max?: number; +} + +export function defaultHistoryPath(): string { + return join(homedir(), ".fastcode", "history"); +} + +/** + * Open the history store. Creates the file on first write. Read failures are + * non-fatal — a corrupt file is silently ignored so the TUI keeps booting. + */ +export function openHistory(opts: OpenOpts = {}): InputHistory { + const path = opts.path ?? defaultHistoryPath(); + const max = Math.max(10, opts.max ?? DEFAULT_MAX); + const entries: HistoryEntry[] = loadFromDisk(path); + + const trim = () => { + if (entries.length <= max) return; + const drop = entries.length - max; + entries.splice(0, drop); + persistFull(path, entries); + }; + + return { + list() { + return entries.slice(); + }, + push(text: string) { + const trimmed = text.trim(); + if (!trimmed) return; + // Skip exact duplicates of the most recent entry. + const last = entries[entries.length - 1]; + if (last && last.text === trimmed) return; + const entry: HistoryEntry = { ts: Date.now(), text: trimmed }; + entries.push(entry); + try { + ensureDir(path); + appendFileSync(path, `${JSON.stringify(entry)}\n`, "utf8"); + } catch { + // Disk full / read-only / Termux denied — stay in-memory. + } + trim(); + }, + recall(reverseIndex: number) { + if (reverseIndex < 0) return null; + const idx = entries.length - 1 - reverseIndex; + if (idx < 0) return null; + return entries[idx] ?? null; + }, + size() { + return entries.length; + }, + }; +} + +function loadFromDisk(path: string): HistoryEntry[] { + if (!existsSync(path)) return []; + try { + const raw = readFileSync(path, "utf8"); + const out: HistoryEntry[] = []; + for (const line of raw.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const obj = JSON.parse(trimmed) as Partial; + if (typeof obj.text === "string" && typeof obj.ts === "number") { + out.push({ ts: obj.ts, text: obj.text }); + } + } catch { + // skip corrupt line + } + } + return out; + } catch { + return []; + } +} + +function persistFull(path: string, entries: HistoryEntry[]): void { + try { + ensureDir(path); + writeFileSync( + path, + entries.map((e) => JSON.stringify(e)).join("\n") + (entries.length ? "\n" : ""), + "utf8", + ); + } catch { + // ignore + } +} + +function ensureDir(path: string): void { + const dir = dirname(path); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); +} diff --git a/src/lib/transcriptVirtual.ts b/src/lib/transcriptVirtual.ts new file mode 100644 index 0000000..58cc335 --- /dev/null +++ b/src/lib/transcriptVirtual.ts @@ -0,0 +1,44 @@ +/** + * Virtual transcript — drops older entries from rendering when the buffer + * grows past a threshold. Returns the visible slice plus a `dropped` count + * so the UI can show a banner like "… 412 earlier messages collapsed". + * + * We keep the full history in app state (so /save still has everything) and + * only filter at render time. That keeps Static reuse stable for entries + * that survive the cut. + */ + +export interface VirtualizeOpts { + /** Soft cap on rendered entries. */ + maxRender: number; + /** Whenever maxRender is exceeded, keep at least this many. */ + keepRecent: number; +} + +export interface VirtualizedView { + visible: T[]; + dropped: number; +} + +export function virtualize(items: T[], opts: VirtualizeOpts): VirtualizedView { + const max = Math.max(opts.keepRecent, opts.maxRender); + if (items.length <= max) return { visible: items, dropped: 0 }; + const keep = Math.max(opts.keepRecent, 1); + return { visible: items.slice(-keep), dropped: items.length - keep }; +} + +/** + * Truncate a long output to N lines, returning a string with a tail marker. + * Used by tool-call result bubbles to keep the chat scannable. + */ +export function clipLines( + text: string, + maxLines: number, + marker = (drop: number) => `… ${drop} more line${drop === 1 ? "" : "s"} (use /show last)`, +): string { + if (maxLines <= 0) return text; + const lines = text.split("\n"); + if (lines.length <= maxLines) return text; + const head = lines.slice(0, maxLines).join("\n"); + return `${head}\n${marker(lines.length - maxLines)}`; +} diff --git a/src/mcp/client.ts b/src/mcp/client.ts new file mode 100644 index 0000000..0c6920e --- /dev/null +++ b/src/mcp/client.ts @@ -0,0 +1,218 @@ +/** + * MCPClient — one per server. JSON-RPC 2.0 over stdio. + * + * Why hand-rolled? The official @modelcontextprotocol/sdk pulls in zod, ws, + * eventsource, and a transport zoo we don't need. We only need stdio for v0.2; + * http/websocket can be added in a later release. + * + * Lifecycle: + * start() → spawn → initialize → tools/list cache filled by manager + * request(method, params, signal?) + * stop() → SIGTERM, then SIGKILL after 3s + * + * Errors: + * - any rpc-level error rejects the originating promise + * - transport-level errors (child exit, parse) emit `disconnect` + */ + +import { spawn, type ChildProcess } from "node:child_process"; +import { EventEmitter } from "node:events"; +import type { MCPServerConfig, MCPToolSpec } from "./types.js"; + +interface PendingRequest { + resolve(value: unknown): void; + reject(err: Error): void; + timer: NodeJS.Timeout; +} + +interface RpcRequest { + jsonrpc: "2.0"; + id: number; + method: string; + params?: unknown; +} + +interface RpcResponse { + jsonrpc: "2.0"; + id: number; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +} + +const PROTOCOL_VERSION = "2024-11-05"; + +export class MCPClient extends EventEmitter { + private nextId = 1; + private inflight = new Map(); + private child?: ChildProcess; + private buffer = ""; + private alive = false; + private stopped = false; + private defaultTimeoutMs: number; + + constructor( + public readonly name: string, + public readonly cfg: MCPServerConfig, + defaultTimeoutMs = 30_000, + ) { + super(); + this.defaultTimeoutMs = cfg.timeoutMs ?? defaultTimeoutMs; + } + + isAlive(): boolean { + return this.alive && !this.stopped; + } + + async start(): Promise { + if (this.cfg.transport !== "stdio") { + throw new Error(`mcp transport not implemented yet: ${this.cfg.transport}`); + } + if (!this.cfg.command) throw new Error(`mcp server "${this.name}": command is required`); + + this.stopped = false; + this.child = spawn(this.cfg.command, this.cfg.args ?? [], { + env: { ...process.env, ...this.cfg.env }, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + + this.child.stdout?.setEncoding("utf8"); + this.child.stdout?.on("data", (chunk: string) => this.onStdout(chunk)); + this.child.stderr?.setEncoding("utf8"); + this.child.stderr?.on("data", (chunk: string) => this.emit("stderr", chunk)); + this.child.on("error", (err) => this.emit("error", err)); + this.child.on("exit", (code, signal) => { + this.alive = false; + // Reject any in-flight request to unblock callers. + for (const [, p] of this.inflight) { + clearTimeout(p.timer); + p.reject(new Error(`mcp ${this.name}: process exited (code=${code} signal=${signal ?? ""})`)); + } + this.inflight.clear(); + if (!this.stopped) this.emit("disconnect", { code, signal }); + }); + + await this.request("initialize", { + protocolVersion: PROTOCOL_VERSION, + clientInfo: { name: "fastcode", version: "0.2.0" }, + capabilities: { tools: {} }, + }); + this.alive = true; + this.emit("connect", { name: this.name }); + } + + async listTools(): Promise { + const r = (await this.request("tools/list")) as { tools?: MCPToolSpec[] }; + return r.tools ?? []; + } + + async callTool( + tool: string, + args: unknown, + opts: { signal?: AbortSignal; timeoutMs?: number } = {}, + ): Promise<{ content: unknown; isError?: boolean }> { + const r = (await this.request( + "tools/call", + { name: tool, arguments: args ?? {} }, + opts, + )) as { content?: unknown; isError?: boolean }; + return { content: r.content, isError: r.isError }; + } + + async stop(): Promise { + this.stopped = true; + if (!this.child) return; + try { + this.child.kill("SIGTERM"); + } catch { + // ignore + } + setTimeout(() => { + try { + this.child?.kill("SIGKILL"); + } catch { + // ignore + } + }, 3000).unref(); + } + + private request( + method: string, + params?: unknown, + opts: { signal?: AbortSignal; timeoutMs?: number } = {}, + ): Promise { + if (!this.child || !this.child.stdin || this.child.stdin.destroyed) { + return Promise.reject(new Error(`mcp ${this.name}: not started`)); + } + const id = this.nextId++; + const timeoutMs = opts.timeoutMs ?? this.defaultTimeoutMs; + const req: RpcRequest = { jsonrpc: "2.0", id, method, params }; + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.inflight.delete(id); + reject(new Error(`mcp ${this.name}.${method}: timeout after ${timeoutMs}ms`)); + }, timeoutMs); + + const onAbort = (): void => { + clearTimeout(timer); + this.inflight.delete(id); + reject(new Error(`mcp ${this.name}.${method}: aborted`)); + }; + if (opts.signal) { + if (opts.signal.aborted) { + onAbort(); + return; + } + opts.signal.addEventListener("abort", onAbort, { once: true }); + } + + this.inflight.set(id, { + resolve: (v) => { + clearTimeout(timer); + opts.signal?.removeEventListener("abort", onAbort); + resolve(v); + }, + reject: (e) => { + clearTimeout(timer); + opts.signal?.removeEventListener("abort", onAbort); + reject(e); + }, + timer, + }); + + try { + this.child!.stdin!.write(`${JSON.stringify(req)}\n`); + } catch (err) { + this.inflight.delete(id); + clearTimeout(timer); + reject(err as Error); + } + }); + } + + private onStdout(chunk: string): void { + this.buffer += chunk; + while (true) { + const idx = this.buffer.indexOf("\n"); + if (idx === -1) break; + const line = this.buffer.slice(0, idx).trim(); + this.buffer = this.buffer.slice(idx + 1); + if (!line) continue; + try { + const msg = JSON.parse(line) as RpcResponse; + if (typeof msg.id !== "number") continue; + const cb = this.inflight.get(msg.id); + if (!cb) continue; + this.inflight.delete(msg.id); + if (msg.error) { + cb.reject(new Error(`mcp ${this.name}: ${msg.error.code} ${msg.error.message}`)); + } else { + cb.resolve(msg.result); + } + } catch (err) { + this.emit("parse-error", err); + } + } + } +} diff --git a/src/mcp/config.ts b/src/mcp/config.ts new file mode 100644 index 0000000..093861f --- /dev/null +++ b/src/mcp/config.ts @@ -0,0 +1,134 @@ +/** + * MCP config loader. + * + * Precedence (later wins): + * 1. ~/.fastcode/mcp.json (global) + * 2. /.fastcode/mcp.json (per-project) + * 3. /mcp.json (also per-project, simple form) + * 4. $FASTCODE_MCP_CONFIG (env) (one-off override) + * 5. CLI flag (resolved by caller and passed in) + * + * `${workspace}` and `${env:NAME}` placeholders are interpolated in command, + * args, env values, headers, and url. + */ + +import { readFileSync, existsSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { safeHome } from "../lib/platform.js"; +import type { + MCPConfigFile, + MCPDefaults, + MCPServerConfig, + ResolvedMCPConfig, +} from "./types.js"; + +const DEFAULTS: MCPDefaults = { + timeoutMs: 30_000, + maxConcurrentCalls: 4, + reconnect: { initialMs: 500, maxMs: 30_000, maxAttempts: 10 }, +}; + +export interface LoadOptions { + workspace?: string; + env?: NodeJS.ProcessEnv; + /** Optional explicit path passed by --mcp CLI flag. */ + cliPath?: string; +} + +export function loadMcpConfig(opts: LoadOptions = {}): ResolvedMCPConfig { + const workspace = opts.workspace ?? process.cwd(); + const env = opts.env ?? process.env; + const home = safeHome(); + + const candidates = [ + join(home, ".fastcode", "mcp.json"), + join(workspace, ".fastcode", "mcp.json"), + join(workspace, "mcp.json"), + env.FASTCODE_MCP_CONFIG ?? undefined, + opts.cliPath, + ].filter((p): p is string => Boolean(p)); + + let merged: MCPConfigFile = { servers: {} }; + for (const path of candidates) { + const file = readIfExists(path); + if (!file) continue; + merged = mergeConfig(merged, file); + } + + const defaults: MCPDefaults = { + ...DEFAULTS, + ...(merged.defaults ?? {}), + reconnect: { ...DEFAULTS.reconnect, ...(merged.defaults?.reconnect ?? {}) }, + }; + + // Interpolate placeholders. + const interp = (s: string): string => interpolate(s, { workspace, env }); + const servers: Record = {}; + for (const [name, srv] of Object.entries(merged.servers)) { + servers[name] = { + ...srv, + command: srv.command ? interp(srv.command) : undefined, + args: srv.args?.map(interp), + env: srv.env + ? Object.fromEntries(Object.entries(srv.env).map(([k, v]) => [k, interp(v)])) + : undefined, + url: srv.url ? interp(srv.url) : undefined, + headers: srv.headers + ? Object.fromEntries(Object.entries(srv.headers).map(([k, v]) => [k, interp(v)])) + : undefined, + }; + } + + return { servers, defaults }; +} + +function readIfExists(path: string): MCPConfigFile | null { + try { + if (!existsSync(path)) return null; + const raw = readFileSync(path, "utf8"); + const parsed = JSON.parse(raw) as MCPConfigFile; + if (typeof parsed !== "object" || parsed === null || typeof parsed.servers !== "object") { + return null; + } + return parsed; + } catch { + return null; + } +} + +function mergeConfig(base: MCPConfigFile, layer: MCPConfigFile): MCPConfigFile { + return { + ...base, + ...layer, + servers: { ...base.servers, ...layer.servers }, + defaults: { ...(base.defaults ?? {}), ...(layer.defaults ?? {}) }, + }; +} + +interface InterpCtx { + workspace: string; + env: NodeJS.ProcessEnv; +} + +export function interpolate(input: string, ctx: InterpCtx): string { + return input.replace(/\$\{([^}]+)\}/g, (_match, expr: string) => { + const trimmed = expr.trim(); + if (trimmed === "workspace") return ctx.workspace; + if (trimmed === "home") return safeHome(); + if (trimmed.startsWith("env:")) { + const name = trimmed.slice(4).trim(); + return ctx.env[name] ?? ""; + } + return ""; + }); +} + +/** For `/sandbox` / `/mcp` summaries. */ +export function defaultMcpPath(workspace = process.cwd()): string { + return join(workspace, ".fastcode", "mcp.json"); +} + +/** Force absolute path resolution against the workspace. */ +export function resolveMcpConfigPath(p: string, workspace = process.cwd()): string { + return resolve(workspace, p); +} diff --git a/src/mcp/index.ts b/src/mcp/index.ts new file mode 100644 index 0000000..5d3c2e4 --- /dev/null +++ b/src/mcp/index.ts @@ -0,0 +1,24 @@ +/** + * Public MCP API for the rest of FastCode. + * + * Other modules import only from "../mcp/index.js" to keep the surface stable + * if we later swap the transport implementation. + */ + +export { MCPClient } from "./client.js"; +export { MCPServerManager } from "./manager.js"; +export { MCPToolRegistry } from "./registry.js"; +export { MCPPermissionManager } from "./permission.js"; +export { loadMcpConfig, defaultMcpPath, interpolate } from "./config.js"; +export type { + MCPServerConfig, + MCPServerSummary, + MCPDefaults, + MCPConfigFile, + ResolvedMCPConfig, + MCPToolSpec, + MCPCallResult, + Permission, + Transport, +} from "./types.js"; +export type { Decision, Risk } from "./permission.js"; diff --git a/src/mcp/manager.ts b/src/mcp/manager.ts new file mode 100644 index 0000000..ede5a93 --- /dev/null +++ b/src/mcp/manager.ts @@ -0,0 +1,170 @@ +/** + * MCPServerManager — owns the lifecycle of every MCPClient configured by the user. + * + * Also bridges into the EventBus so the rest of the app (status bar, logo, + * confirm dialog) reacts to mcp:* events without holding refs to clients. + */ + +import { MCPClient } from "./client.js"; +import { MCPToolRegistry } from "./registry.js"; +import { MCPPermissionManager, type Decision, type Risk } from "./permission.js"; +import { bus } from "../core/EventBus.js"; +import type { + MCPCallResult, + MCPServerSummary, + ResolvedMCPConfig, + MCPServerConfig, +} from "./types.js"; + +interface ManagedServer { + name: string; + client: MCPClient; + cfg: MCPServerConfig; + status: MCPServerSummary["status"]; + lastError?: string; + attempts: number; +} + +export class MCPServerManager { + private servers = new Map(); + readonly registry = new MCPToolRegistry(); + readonly permissions = new MCPPermissionManager(); + + constructor(private cfg: ResolvedMCPConfig) {} + + /** Spawn every non-disabled server, in parallel. Errors don't stop siblings. */ + async startAll(): Promise { + const entries = Object.entries(this.cfg.servers); + await Promise.all(entries.map(([name, srv]) => this.startOne(name, srv).catch(() => {}))); + } + + async startOne(name: string, srv: MCPServerConfig): Promise { + if (srv.disabled) { + this.servers.set(name, { + name, + client: new MCPClient(name, srv, this.cfg.defaults.timeoutMs), + cfg: srv, + status: "disabled", + attempts: 0, + }); + return; + } + + const client = new MCPClient(name, srv, this.cfg.defaults.timeoutMs); + const slot: ManagedServer = { + name, + client, + cfg: srv, + status: "starting", + attempts: 0, + }; + this.servers.set(name, slot); + + client.on("disconnect", () => { + slot.status = "reconnecting"; + this.registry.remove(name); + bus.emit("mcp:disconnected", { name, reason: slot.lastError ?? "exit" }); + // We don't auto-restart in v0.2 to keep behavior predictable; users run /mcp restart. + }); + client.on("stderr", () => { + // Surfaced via /mcp status if needed. + }); + client.on("error", (err: Error) => { + slot.lastError = err.message; + }); + + try { + await client.start(); + const tools = await client.listTools(); + this.registry.set(name, tools); + slot.status = "connected"; + bus.emit("mcp:connected", { name, toolCount: tools.length }); + } catch (err) { + slot.status = "failed"; + slot.lastError = (err as Error).message; + this.registry.remove(name); + } + } + + async stopAll(): Promise { + await Promise.all([...this.servers.values()].map((s) => s.client.stop())); + this.servers.clear(); + // Keep registry empty so a fresh `startAll` rebuilds. + for (const { name } of [...this.servers.values()]) this.registry.remove(name); + } + + async restart(name: string): Promise { + const slot = this.servers.get(name); + if (!slot) throw new Error(`mcp: unknown server "${name}"`); + await slot.client.stop(); + this.registry.remove(name); + await this.startOne(name, slot.cfg); + } + + setPermission(name: string, p: "auto" | "confirm" | "deny"): void { + this.permissions.setServerPermission(name, p); + } + + /** Decide whether a tool call is `allow` / `ask` / `deny` and the inferred risk. */ + decide(qualified: string): { decision: Decision; risk: Risk; server: string; tool: string } | null { + const entry = this.registry.resolve(qualified); + if (!entry) return null; + const slot = this.servers.get(entry.server); + if (!slot) return null; + const { decision, risk } = this.permissions.classify(entry.server, entry.spec.name, slot.cfg); + return { decision, risk, server: entry.server, tool: entry.spec.name }; + } + + async call(qualified: string, args: unknown, signal?: AbortSignal): Promise { + const entry = this.registry.resolve(qualified); + if (!entry) return { ok: false, output: `mcp: unknown tool "${qualified}"` }; + const slot = this.servers.get(entry.server); + if (!slot || slot.status !== "connected") { + return { ok: false, output: `mcp: server "${entry.server}" is ${slot?.status ?? "missing"}` }; + } + bus.emit("mcp:tool-called", { server: entry.server, tool: entry.spec.name }); + try { + const r = await slot.client.callTool(entry.spec.name, args ?? {}, { signal }); + return { + ok: !r.isError, + output: stringifyContent(r.content), + raw: r, + }; + } catch (err) { + return { ok: false, output: (err as Error).message }; + } + } + + list(): MCPServerSummary[] { + return [...this.servers.values()].map((s) => ({ + name: s.name, + status: s.status, + toolCount: this.registry.listForServer(s.name).length, + lastError: s.lastError, + tags: s.cfg.tags ?? [], + permission: s.cfg.permission ?? "auto", + })); + } +} + +function stringifyContent(content: unknown): string { + if (content === undefined || content === null) return ""; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + // MCP content blocks are usually [{type, text}, ...]. + const parts = content + .map((b) => { + if (b && typeof b === "object" && "text" in (b as Record)) { + return String((b as Record).text ?? ""); + } + return JSON.stringify(b); + }) + .filter((s) => s); + return parts.join("\n"); + } + try { + return JSON.stringify(content, null, 2); + } catch { + return String(content); + } +} diff --git a/src/mcp/permission.ts b/src/mcp/permission.ts new file mode 100644 index 0000000..f3684a6 --- /dev/null +++ b/src/mcp/permission.ts @@ -0,0 +1,64 @@ +/** + * MCP permission classifier. + * + * Computes a risk level for a tool call based on: + * 1. Server-level `permission` (auto | confirm | deny) + * 2. Server tags ("network", "risky", "destructive") + * 3. Tool-name regex (delete/write/exec → bumped to high) + * 4. Per-tool overrides (set via /mcp permission) + * + * The agent never blocks itself — it always asks the manager, which either + * returns "allow", "ask" (waiting state in the FSM), or "deny" (rejected). + */ + +import type { MCPServerConfig, Permission } from "./types.js"; + +export type Risk = "low" | "med" | "high"; +export type Decision = "allow" | "ask" | "deny"; + +const RISKY_NAME_RE = /(delete|destroy|drop|reset|exec|spawn|kill|wipe|format)/i; +const WRITE_NAME_RE = /(write|create|update|modify|patch|push|publish|deploy)/i; + +export class MCPPermissionManager { + private overrides = new Map(); + + setServerPermission(server: string, p: Permission): void { + this.overrides.set(`server:${server}`, p); + } + + setToolPermission(server: string, tool: string, p: Permission): void { + this.overrides.set(`tool:${server}.${tool}`, p); + } + + classify(server: string, tool: string, cfg: MCPServerConfig): { risk: Risk; decision: Decision } { + const tags = new Set(cfg.tags ?? []); + let risk: Risk = "low"; + if (tags.has("network")) risk = "med"; + if (tags.has("risky") || tags.has("destructive")) risk = "high"; + if (WRITE_NAME_RE.test(tool)) risk = bump(risk, "med"); + if (RISKY_NAME_RE.test(tool)) risk = "high"; + + const perm = + this.overrides.get(`tool:${server}.${tool}`) ?? + this.overrides.get(`server:${server}`) ?? + cfg.permission ?? + defaultPermissionFor(risk); + + let decision: Decision; + if (perm === "deny") decision = "deny"; + else if (perm === "auto") decision = risk === "high" ? "ask" : "allow"; + else if (perm === "confirm") decision = "ask"; + else decision = "ask"; + + return { risk, decision }; + } +} + +function bump(current: Risk, target: Risk): Risk { + const order: Risk[] = ["low", "med", "high"]; + return order.indexOf(target) > order.indexOf(current) ? target : current; +} + +function defaultPermissionFor(risk: Risk): Permission { + return risk === "high" ? "confirm" : "auto"; +} diff --git a/src/mcp/registry.ts b/src/mcp/registry.ts new file mode 100644 index 0000000..7e00ae1 --- /dev/null +++ b/src/mcp/registry.ts @@ -0,0 +1,66 @@ +/** + * MCPToolRegistry — flat directory of every tool advertised by every server. + * + * Tool names are namespaced by `.` so the agent / planner can + * disambiguate when two servers expose tools with overlapping names (e.g. + * "filesystem.read" vs "github.read"). + */ + +import type { MCPToolSpec } from "./types.js"; + +export interface RegistryEntry { + server: string; + spec: MCPToolSpec; +} + +export class MCPToolRegistry { + private byServer = new Map(); + + set(server: string, tools: MCPToolSpec[]): void { + this.byServer.set(server, [...tools]); + } + + remove(server: string): void { + this.byServer.delete(server); + } + + /** All tools across all servers, namespaced as `server.tool`. */ + list(): RegistryEntry[] { + const out: RegistryEntry[] = []; + for (const [server, tools] of this.byServer) { + for (const spec of tools) out.push({ server, spec }); + } + return out; + } + + listForServer(server: string): MCPToolSpec[] { + return [...(this.byServer.get(server) ?? [])]; + } + + /** Resolve a fully-qualified `server.tool` (or short `tool` if unique). */ + resolve(qualified: string): RegistryEntry | null { + const dot = qualified.indexOf("."); + if (dot > 0) { + const server = qualified.slice(0, dot); + const tool = qualified.slice(dot + 1); + const spec = this.byServer.get(server)?.find((s) => s.name === tool); + return spec ? { server, spec } : null; + } + let hit: RegistryEntry | null = null; + for (const [server, tools] of this.byServer) { + const spec = tools.find((t) => t.name === qualified); + if (!spec) continue; + if (hit) return null; // ambiguous + hit = { server, spec }; + } + return hit; + } + + /** Snapshot used by /mcp status. */ + servers(): Array<{ server: string; toolCount: number }> { + return [...this.byServer.entries()].map(([server, tools]) => ({ + server, + toolCount: tools.length, + })); + } +} diff --git a/src/mcp/types.ts b/src/mcp/types.ts new file mode 100644 index 0000000..3e40661 --- /dev/null +++ b/src/mcp/types.ts @@ -0,0 +1,86 @@ +/** + * MCP — Model Context Protocol types used across the client / registry / manager. + * + * We intentionally keep the wire schema minimal — the official MCP TypeScript + * SDK is heavyweight and pulls in many transitive deps. FastCode targets a + * single TUI binary that has to run on Termux, so we hand-roll the bits we need. + */ + +export type Transport = "stdio" | "http" | "websocket"; + +export type Permission = "auto" | "confirm" | "deny"; + +export interface MCPServerConfig { + transport: Transport; + /** stdio: process to spawn. */ + command?: string; + args?: string[]; + env?: Record; + /** http / websocket. */ + url?: string; + headers?: Record; + /** Default risk policy for tool calls on this server. */ + permission?: Permission; + /** Per-server tool-call timeout. Defaults to `defaults.timeoutMs`. */ + timeoutMs?: number; + /** Soft-disable: schemas can still be inspected, calls are blocked. */ + disabled?: boolean; + /** Free-form labels surfaced in /mcp status — also used by risk classifier. */ + tags?: string[]; +} + +export interface MCPDefaults { + timeoutMs: number; + maxConcurrentCalls: number; + reconnect: { initialMs: number; maxMs: number; maxAttempts: number }; +} + +export interface MCPConfigFile { + $schema?: string; + servers: Record; + defaults?: Partial; +} + +export interface ResolvedMCPConfig { + servers: Record; + defaults: MCPDefaults; +} + +export interface MCPToolSpec { + name: string; + description?: string; + inputSchema?: unknown; +} + +export interface MCPToolHandle { + /** Server name as declared in mcp.json. */ + server: string; + /** Tool name as advertised by the server. */ + tool: string; +} + +export interface MCPCallResult { + /** True iff the server returned `result` (no error). */ + ok: boolean; + /** Stringified content for the chat / agent. */ + output: string; + /** Raw protocol response — kept for power users / debug mode. */ + raw?: unknown; +} + +export type MCPServerStatus = + | "idle" + | "starting" + | "connected" + | "reconnecting" + | "failed" + | "disabled"; + +export interface MCPServerSummary { + name: string; + status: MCPServerStatus; + toolCount: number; + lastError?: string; + tags: string[]; + permission: Permission; +} diff --git a/src/platform/TerminalCapabilities.ts b/src/platform/TerminalCapabilities.ts new file mode 100644 index 0000000..e2a7db1 --- /dev/null +++ b/src/platform/TerminalCapabilities.ts @@ -0,0 +1,207 @@ +/** + * Single source of truth about the host terminal. + * + * Every UI module (LogoAnimator, ThemeManager, LayoutManager, ChatView) + * reads from a TerminalCapabilities snapshot rather than peppering + * `process.platform === "..."` checks across the codebase. + * + * Detection happens once at startup. The snapshot is stable for the + * session; resize events update only `size` and `sizeClass` (re-detect + * via `redetectSize()`). + */ + +import { existsSync } from "node:fs"; +import { detectPlatform, type Platform } from "../lib/platform.js"; + +export type ColorDepth = 0 | 4 | 8 | 24; +export type SizeClass = "tiny" | "compact" | "normal" | "wide"; +export type UnicodeWidth = "narrow" | "wide" | "unknown"; + +export interface TerminalCapabilities { + os: Platform; + shell: "bash" | "zsh" | "fish" | "pwsh" | "cmd" | "sh" | "unknown"; + isWSL: boolean; + isSSH: boolean; + isCI: boolean; + isInteractive: boolean; + isDumb: boolean; + colorDepth: ColorDepth; + unicodeWidth: UnicodeWidth; + emojiSafe: boolean; + size: { columns: number; rows: number }; + sizeClass: SizeClass; + supportsRawMode: boolean; + supportsCursorHide: boolean; + supportsAlternateScreen: boolean; + clipboard: ClipboardKind; + /** Convenience flags for code that just wants a bool. */ + forceStableUi: boolean; + forceNoAnim: boolean; + forceNoColor: boolean; +} + +export type ClipboardKind = + | "pbcopy" + | "xclip" + | "xsel" + | "wl-copy" + | "clip.exe" + | "termux-clipboard" + | "none"; + +type Env = Partial>; + +const CI_ENVS = [ + "CI", + "GITHUB_ACTIONS", + "GITLAB_CI", + "CIRCLECI", + "BUILDKITE", + "TRAVIS", + "JENKINS_URL", + "TF_BUILD", +]; + +export function detectCapabilities(env: Env = process.env): TerminalCapabilities { + const os = detectPlatform(); + const shell = detectShell(os, env); + const isWSL = detectWSL(env); + const isSSH = Boolean(env.SSH_CLIENT || env.SSH_TTY || env.SSH_CONNECTION); + const isCI = CI_ENVS.some((name) => env[name] && env[name] !== "false"); + const isInteractive = Boolean( + process.stdin.isTTY && process.stdout.isTTY && !isCI, + ); + // Only treat the terminal as "dumb" when explicitly so. Missing TERM is + // common in CI / piped contexts and shouldn't strip color/unicode for callers + // that pass an explicit COLORTERM or LANG. + const isDumb = env.TERM === "dumb"; + const colorDepth = detectColorDepth(env, isDumb); + const unicodeWidth = detectUnicodeWidth(env, os); + const emojiSafe = detectEmojiSafe(env, os); + + const columns = process.stdout.columns ?? 80; + const rows = process.stdout.rows ?? 24; + const sizeClass = classifySize(columns); + + const clipboard = detectClipboard(os, env); + + return { + os, + shell, + isWSL, + isSSH, + isCI, + isInteractive, + isDumb, + colorDepth, + unicodeWidth, + emojiSafe, + size: { columns, rows }, + sizeClass, + supportsRawMode: isInteractive && !isDumb, + supportsCursorHide: isInteractive && !isDumb, + supportsAlternateScreen: isInteractive && !isDumb && os !== "termux", + clipboard, + forceStableUi: + env.FASTCODE_STABLE_UI === "1" || os === "termux" || isCI || isDumb, + forceNoAnim: + env.FASTCODE_NO_ANIM === "1" || env.NO_ANIMATION === "1" || isCI || isDumb, + forceNoColor: + env.FASTCODE_NO_COLOR === "1" || env.NO_COLOR === "1" || colorDepth === 0, + }; +} + +/** Used after a SIGWINCH or Ink resize. Only `size` and `sizeClass` change. */ +export function redetectSize( + prev: TerminalCapabilities, + columns = process.stdout.columns ?? 80, + rows = process.stdout.rows ?? 24, +): TerminalCapabilities { + return { + ...prev, + size: { columns, rows }, + sizeClass: classifySize(columns), + }; +} + +function classifySize(columns: number): SizeClass { + if (columns < 60) return "tiny"; + if (columns < 100) return "compact"; + if (columns < 130) return "normal"; + return "wide"; +} + +function detectShell(os: Platform, env: Env): TerminalCapabilities["shell"] { + if (os === "win32") { + const psm = env.PSModulePath; + if (psm) return "pwsh"; + const comspec = env.COMSPEC ?? ""; + if (comspec.toLowerCase().endsWith("cmd.exe")) return "cmd"; + return "cmd"; + } + const sh = (env.SHELL ?? "").toLowerCase(); + if (sh.endsWith("/zsh")) return "zsh"; + if (sh.endsWith("/bash")) return "bash"; + if (sh.endsWith("/fish")) return "fish"; + if (sh.endsWith("/sh")) return "sh"; + return "unknown"; +} + +function detectWSL(env: Env): boolean { + if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) return true; + try { + if (existsSync("/proc/version")) { + // Avoid throwing on permission errors; existence is enough of a hint. + // We don't read the file contents here to keep this synchronous and + // boot-fast; the env-var path covers WSL2. + } + } catch { + // ignore + } + return false; +} + +function detectColorDepth(env: Env, isDumb: boolean): ColorDepth { + if (isDumb) return 0; + if (env.NO_COLOR || env.FASTCODE_NO_COLOR === "1") return 0; + if (env.FORCE_COLOR === "0") return 0; + if (env.FORCE_COLOR === "3" || env.COLORTERM === "truecolor" || env.COLORTERM === "24bit") return 24; + if (env.FORCE_COLOR === "2") return 8; + if (env.FORCE_COLOR === "1") return 4; + // Fall back on TERM heuristics. + const term = (env.TERM ?? "").toLowerCase(); + if (term.includes("256color")) return 8; + if (term.includes("color")) return 4; + // Modern macOS Terminal / iTerm2 / Windows Terminal default truecolor. + if (env.TERM_PROGRAM === "iTerm.app" || env.TERM_PROGRAM === "WezTerm") return 24; + if (env.WT_SESSION) return 24; + return 8; +} + +function detectUnicodeWidth(env: Env, os: Platform): UnicodeWidth { + // Strict enough to avoid double-width glitches on legacy Windows cmd. + if (os === "win32") { + if (env.WT_SESSION || env.TERM_PROGRAM) return "wide"; + return "narrow"; + } + const lang = (env.LC_ALL ?? env.LC_CTYPE ?? env.LANG ?? "").toLowerCase(); + if (lang.includes("utf-8") || lang.includes("utf8")) return "wide"; + if (!lang) return "unknown"; + return "narrow"; +} + +function detectEmojiSafe(env: Env, os: Platform): boolean { + if (os === "termux") return false; + if (os === "win32" && !env.WT_SESSION) return false; + const lang = (env.LC_ALL ?? env.LC_CTYPE ?? env.LANG ?? "").toLowerCase(); + return lang.includes("utf-8") || lang.includes("utf8"); +} + +function detectClipboard(os: Platform, env: Env): ClipboardKind { + if (os === "darwin") return "pbcopy"; + if (os === "win32") return "clip.exe"; + if (os === "termux") return "termux-clipboard"; + if (env.WAYLAND_DISPLAY) return "wl-copy"; + if (env.DISPLAY) return "xclip"; + return "none"; +} diff --git a/src/sandbox/PathGuard.ts b/src/sandbox/PathGuard.ts new file mode 100644 index 0000000..26876d6 --- /dev/null +++ b/src/sandbox/PathGuard.ts @@ -0,0 +1,126 @@ +/** + * PathGuard — every fs path the agent touches resolves through this. + * + * Behavior: + * - Absolute paths must start with `` (after realpath of the + * existing portion to defeat symlink escapes). + * - Relative paths resolve against ``. + * - `..` segments that escape are rejected. + * - `denyGlobs` (e.g. `**`/`.env`, `**`/`secrets/**`) blocked even inside root. + * + * The guard does NOT create paths. It only validates them. Callers (write_file, + * apply_patch, etc.) are responsible for `mkdir -p`. + */ + +import { realpathSync } from "node:fs"; +import { isAbsolute, resolve, sep, relative } from "node:path"; +import { SandboxError } from "./errors.js"; + +export interface PathGuardOptions { + /** Glob list (simple `*` and `**`/`*` semantics) of always-denied paths. */ + denyGlobs?: string[]; + /** When false (default), symlinks that escape root are rejected. */ + allowSymlinks?: boolean; +} + +export class PathGuard { + private readonly root: string; + private readonly denyMatchers: RegExp[]; + private readonly allowSymlinks: boolean; + + constructor(root: string, opts: PathGuardOptions = {}) { + this.root = resolve(root); + this.denyMatchers = (opts.denyGlobs ?? []).map(globToRegex); + this.allowSymlinks = Boolean(opts.allowSymlinks); + } + + /** Validates and returns the absolute path, or throws SandboxError. */ + check(input: string): string { + if (typeof input !== "string" || input.length === 0) { + throw new SandboxError("PATH_OUT_OF_ROOT", "empty path"); + } + + const abs = isAbsolute(input) ? resolve(input) : resolve(this.root, input); + + // Resolve only the existing prefix so "yet-to-be-created" files validate. + let real = abs; + if (!this.allowSymlinks) { + try { + real = realpathSync(abs); + } catch { + // path doesn't exist yet — fine + } + } + + if (real !== this.root && !real.startsWith(this.root + sep)) { + throw new SandboxError( + "PATH_OUT_OF_ROOT", + `${input} escapes sandbox root ${this.root}`, + ); + } + + const rel = relative(this.root, real); + for (const re of this.denyMatchers) { + if (re.test(rel)) { + throw new SandboxError("PATH_DENIED", `${input} matches deny rule`); + } + } + + return abs; + } + + getRoot(): string { + return this.root; + } +} + +/** + * Translate a gitignore-style glob into a RegExp: + * `**\/` ⟶ zero or more directory segments (so `**\/.env` matches `.env` too) + * `**` ⟶ any characters across separators + * `*` ⟶ one path segment (no `/`) + * `?` ⟶ one non-`/` char + * Everything else is escaped. + */ +export function globToRegex(glob: string): RegExp { + let out = ""; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + const next = glob[i + 1]; + const after = glob[i + 2]; + if (c === "*" && next === "*" && after === "/") { + // `**/` ⟶ zero or more directory segments. + out += "(?:.*[/\\\\])?"; + i += 3; + continue; + } + if (c === "*" && next === "*") { + out += ".*"; + i += 2; + continue; + } + if (c === "*") { + out += "[^/\\\\]*"; + i += 1; + continue; + } + if (c === "?") { + out += "[^/\\\\]"; + i += 1; + continue; + } + if (c === "/") { + out += "[/\\\\]"; + i += 1; + continue; + } + if (/[.+^${}()|[\]\\]/.test(c!)) { + out += `\\${c}`; + } else { + out += c; + } + i += 1; + } + return new RegExp(`^${out}$`); +} diff --git a/src/sandbox/errors.ts b/src/sandbox/errors.ts new file mode 100644 index 0000000..5360e99 --- /dev/null +++ b/src/sandbox/errors.ts @@ -0,0 +1,20 @@ +/** + * Sandbox-related errors. Kept in their own module so consumers can import + * just the types without pulling in the runtime guards. + */ + +export type SandboxErrorCode = + | "PATH_OUT_OF_ROOT" + | "PATH_DENIED" + | "SHELL_DENIED" + | "NETWORK_DENIED" + | "POLICY_INVALID"; + +export class SandboxError extends Error { + readonly code: SandboxErrorCode; + constructor(code: SandboxErrorCode, message: string) { + super(message); + this.name = "SandboxError"; + this.code = code; + } +} diff --git a/src/sandbox/index.ts b/src/sandbox/index.ts new file mode 100644 index 0000000..3ec5623 --- /dev/null +++ b/src/sandbox/index.ts @@ -0,0 +1,13 @@ +/** + * Public sandbox API. + */ +export { PathGuard, globToRegex } from "./PathGuard.js"; +export { SandboxError } from "./errors.js"; +export type { SandboxErrorCode } from "./errors.js"; +export { + loadPolicy, + isHostAllowed, + assertShellAllowed, + defaultPolicyPath, +} from "./policy.js"; +export type { PolicyFile, ResolvedPolicy, LoadPolicyOptions } from "./policy.js"; diff --git a/src/sandbox/policy.ts b/src/sandbox/policy.ts new file mode 100644 index 0000000..cff1ee1 --- /dev/null +++ b/src/sandbox/policy.ts @@ -0,0 +1,194 @@ +/** + * Sandbox policy loader and runtime guards. + * + * Policy layout (`./.fastcode/policy.json`): + * + * { + * "fs": { + * "root": "${workspace}", + * "deny": ["**\/.env", "**\/secrets/**"], + * "allowSymlinks": false + * }, + * "shell": { + * "enabled": true, + * "denyPatterns": ["^rm\\s+-rf\\s+/"] + * }, + * "network": { + * "egressAllow": ["api.anthropic.com", "*.openai.com", "registry.npmjs.org"], + * "egressDenyDefault": true + * }, + * "risk": { + * "autoApprove": ["read_file", "list_files", "grep"], + * "alwaysConfirm": ["shell", "apply_patch", "write_file"] + * } + * } + */ + +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { interpolate } from "../mcp/config.js"; +import { PathGuard, globToRegex } from "./PathGuard.js"; +import { SandboxError } from "./errors.js"; + +export interface PolicyFile { + fs?: { + root?: string; + deny?: string[]; + allowSymlinks?: boolean; + }; + shell?: { + enabled?: boolean; + denyPatterns?: string[]; + }; + network?: { + egressAllow?: string[]; + egressDenyDefault?: boolean; + }; + risk?: { + autoApprove?: string[]; + alwaysConfirm?: string[]; + }; +} + +export interface ResolvedPolicy { + pathGuard: PathGuard; + shellEnabled: boolean; + shellDenyMatchers: RegExp[]; + egressAllow: string[]; + egressDenyDefault: boolean; + riskAutoApprove: Set; + riskAlwaysConfirm: Set; + raw: PolicyFile; +} + +const DEFAULT_DENY_GLOBS = [ + "**/.env", + "**/.env.*", + "**/*.pem", + "**/secrets/**", + "**/.aws/credentials", + "**/id_rsa", + "**/id_ed25519", +]; + +const DEFAULT_SHELL_DENY = [ + "^\\s*rm\\s+-rf\\s+/(\\s|$)", + "^\\s*:\\(\\)\\s*\\{\\s*:\\s*\\|\\s*:\\s*&\\s*\\}", + "mkfs\\.", + "dd\\s+if=/dev/zero", +]; + +export interface LoadPolicyOptions { + workspace?: string; + env?: NodeJS.ProcessEnv; + /** When provided, bypass file lookup and use this object directly. */ + override?: PolicyFile; +} + +export function loadPolicy(opts: LoadPolicyOptions = {}): ResolvedPolicy { + const workspace = opts.workspace ?? process.cwd(); + const env = opts.env ?? process.env; + + let raw: PolicyFile = {}; + if (opts.override) { + raw = opts.override; + } else { + const candidates = [ + join(workspace, ".fastcode", "policy.json"), + join(workspace, "fastcode.policy.json"), + ]; + for (const path of candidates) { + if (!existsSync(path)) continue; + try { + raw = JSON.parse(readFileSync(path, "utf8")) as PolicyFile; + break; + } catch { + throw new SandboxError("POLICY_INVALID", `failed to parse ${path}`); + } + } + } + + const interp = (s: string): string => interpolate(s, { workspace, env }); + const root = raw.fs?.root ? interp(raw.fs.root) : workspace; + const deny = (raw.fs?.deny ?? DEFAULT_DENY_GLOBS).map(interp); + + const pathGuard = new PathGuard(root, { + denyGlobs: deny, + allowSymlinks: raw.fs?.allowSymlinks ?? false, + }); + + const shellEnabled = raw.shell?.enabled !== false; + const shellDenyPatterns = raw.shell?.denyPatterns ?? DEFAULT_SHELL_DENY; + const shellDenyMatchers = shellDenyPatterns.map((p) => new RegExp(p)); + + const egressAllow = (raw.network?.egressAllow ?? []).map(interp); + const egressDenyDefault = raw.network?.egressDenyDefault ?? false; + + const riskAutoApprove = new Set( + raw.risk?.autoApprove ?? [ + "read_file", + "read_many", + "list_files", + "grep", + "stat_path", + "pwd", + "browser_fetch", + "browser_text", + ], + ); + const riskAlwaysConfirm = new Set( + raw.risk?.alwaysConfirm ?? ["shell", "apply_patch", "write_file", "delete_file"], + ); + + return { + pathGuard, + shellEnabled, + shellDenyMatchers, + egressAllow, + egressDenyDefault, + riskAutoApprove, + riskAlwaysConfirm, + raw, + }; +} + +/** Returns true if the host (or any wildcard pattern) is on the allow list. */ +export function isHostAllowed(host: string, allow: string[]): boolean { + const lower = host.toLowerCase(); + for (const pattern of allow) { + const p = pattern.toLowerCase(); + if (p === lower) return true; + if (p.startsWith("*.")) { + const suffix = p.slice(1); // ".example.com" + if (lower.endsWith(suffix)) return true; + } + // Allow simple wildcard via globToRegex (covers things like "192.168.*"). + if (p.includes("*")) { + try { + if (globToRegex(p).test(lower)) return true; + } catch { + // ignore malformed entries + } + } + } + return false; +} + +/** Throws SandboxError if any deny pattern matches the command. */ +export function assertShellAllowed(command: string, policy: ResolvedPolicy): void { + if (!policy.shellEnabled) { + throw new SandboxError("SHELL_DENIED", "shell is disabled by policy"); + } + for (const re of policy.shellDenyMatchers) { + if (re.test(command)) { + throw new SandboxError( + "SHELL_DENIED", + `command blocked by policy (${re.source})`, + ); + } + } +} + +export function defaultPolicyPath(workspace = process.cwd()): string { + return join(workspace, ".fastcode", "policy.json"); +} diff --git a/src/tui/DebugPanel.tsx b/src/tui/DebugPanel.tsx new file mode 100644 index 0000000..c88f726 --- /dev/null +++ b/src/tui/DebugPanel.tsx @@ -0,0 +1,75 @@ +/** + * DebugPanel — surfaces FSM state, terminal caps, MCP server count, and the + * recent EventBus tail in a single small panel. Only mounted when uiMode + * is "debug" so production sessions stay clean. + */ +import React from "react"; +import { Box, Text } from "ink"; +import type { Theme } from "../types.js"; +import type { AgentSnapshot } from "../core/AgentStateMachine.js"; +import type { TerminalCapabilities } from "../platform/TerminalCapabilities.js"; + +export interface DebugPanelProps { + theme: Theme; + caps: TerminalCapabilities; + snapshot: AgentSnapshot; + /** Optional subtitles like MCP server count / browser engine kind. */ + extras?: Array<[string, string]>; +} + +export const DebugPanel: React.FC = ({ + theme, + caps, + snapshot, + extras, +}) => { + return ( + + + debug + + + + {snapshot.detail ? : null} + {snapshot.errorCode ? ( + + ) : null} + + Boolean(s)) + .join(" ") || "none"} + /> + {extras?.map(([k, v]) => )} + + ); +}; + +const Row: React.FC<{ theme: Theme; k: string; v: string }> = ({ theme, k, v }) => ( + + + {k} + + {v} + +); diff --git a/src/tui/DiffView.tsx b/src/tui/DiffView.tsx new file mode 100644 index 0000000..aac4a5b --- /dev/null +++ b/src/tui/DiffView.tsx @@ -0,0 +1,107 @@ +/** + * DiffView — renders a unified diff with themed +/- coloring and a stable + * column layout. Splits on first parse so wrapping a long diff doesn't reflow + * the whole bubble on every redraw. + * + * We accept either a pre-parsed list of lines or a raw unified diff string. + * The parser is deliberately small — full diff3/word-diff would belong in a + * dedicated library, not the TUI shell. + */ +import React from "react"; +import { Box, Text } from "ink"; +import type { Theme } from "../types.js"; + +export type DiffLineKind = "context" | "add" | "del" | "header" | "hunk" | "meta"; + +export interface DiffLine { + kind: DiffLineKind; + text: string; +} + +export interface DiffViewProps { + theme: Theme; + diff: string | DiffLine[]; + /** Optional max lines to render (older diffs collapse). */ + maxLines?: number; +} + +export function parseDiff(text: string): DiffLine[] { + const out: DiffLine[] = []; + for (const raw of text.split("\n")) { + if (raw === "") { + out.push({ kind: "context", text: "" }); + continue; + } + if (raw.startsWith("diff --git ") || raw.startsWith("index ")) { + out.push({ kind: "meta", text: raw }); + continue; + } + if (raw.startsWith("--- ") || raw.startsWith("+++ ")) { + out.push({ kind: "header", text: raw }); + continue; + } + if (raw.startsWith("@@")) { + out.push({ kind: "hunk", text: raw }); + continue; + } + if (raw.startsWith("+") && !raw.startsWith("+++")) { + out.push({ kind: "add", text: raw }); + continue; + } + if (raw.startsWith("-") && !raw.startsWith("---")) { + out.push({ kind: "del", text: raw }); + continue; + } + out.push({ kind: "context", text: raw }); + } + return out; +} + +export const DiffView: React.FC = ({ theme, diff, maxLines }) => { + const lines = typeof diff === "string" ? parseDiff(diff) : diff; + const limit = maxLines ?? 200; + const truncated = lines.length > limit; + const view = truncated ? lines.slice(0, limit) : lines; + + return ( + + {view.map((ln, i) => ( + + ))} + {truncated ? ( + + + … {lines.length - limit} more line{lines.length - limit === 1 ? "" : "s"} + + + ) : null} + + ); +}; + +const DiffRow: React.FC<{ line: DiffLine; theme: Theme }> = ({ line, theme }) => { + const addColor = theme.diffAdd ?? theme.success; + const delColor = theme.diffDel ?? theme.error; + switch (line.kind) { + case "add": + return {line.text}; + case "del": + return {line.text}; + case "hunk": + return ( + + {line.text} + + ); + case "header": + return ( + + {line.text} + + ); + case "meta": + return {line.text}; + default: + return {line.text || " "}; + } +}; diff --git a/src/tui/LogoAnimator.tsx b/src/tui/LogoAnimator.tsx new file mode 100644 index 0000000..9b78037 --- /dev/null +++ b/src/tui/LogoAnimator.tsx @@ -0,0 +1,105 @@ +/** + * Living-logo animator. + * + * Renders a status-reactive core glyph row driven by AgentStateMachine snapshots. + * Honors TerminalCapabilities — Termux / SSH / no-color / dumb terminals + * automatically downgrade to ASCII or static frames. + * + * The component is intentionally tiny: it owns one `setInterval` whose period + * is dictated by the current state. When the parent unmounts it, or when + * `forceNoAnim` is set, no timers are spawned. + */ + +import React, { useEffect, useState } from "react"; +import { Box, Text } from "ink"; +import type { AgentState } from "../core/AgentStateMachine.js"; +import type { TerminalCapabilities } from "../platform/TerminalCapabilities.js"; +import type { Theme } from "../types.js"; + +const CORE_UNICODE: Record = { + idle: ["◆◇◆◇◆◇", "◇◆◇◆◇◆"], + thinking: ["▰▱▰▱▰▱", "▱▰▱▰▱▰", "▰▰▱▰▱▱", "▱▰▰▱▰▱"], + executing: [ + "▶▷▷▷▷▷", + "▷▶▷▷▷▷", + "▷▷▶▷▷▷", + "▷▷▷▶▷▷", + "▷▷▷▷▶▷", + "▷▷▷▷▷▶", + ], + waiting: ["…⏸…⏸…⏸", "⏸…⏸…⏸…"], + success: ["★ ✓ ★ ✓ ★ ✓"], + error: ["▲ ! ▲ ! ▲ !", "! ▲ ! ▲ ! ▲"], +}; + +const CORE_ASCII: Record = { + idle: ["<*><*>", "> < > <"], + thinking: ["====-", "-====", "--==="], + executing: [">>>>>", " >>>>", " >>>", " >>", " >"], + waiting: ["...|", "|..."], + success: ["[ok]"], + error: ["[!!!]"], +}; + +const SPEED_MS: Record = { + idle: 800, + thinking: 180, + executing: 120, + waiting: 700, + success: 600, + error: 800, +}; + +interface Props { + caps: TerminalCapabilities; + theme: Theme; + state: AgentState; + /** Optional textual label shown to the right of the core. */ + label?: string; +} + +export const LogoAnimator: React.FC = ({ caps, theme, state, label }) => { + const useUnicode = caps.unicodeWidth !== "narrow" && caps.colorDepth >= 4; + const frames = useUnicode ? CORE_UNICODE[state] : CORE_ASCII[state]; + const [tick, setTick] = useState(0); + + useEffect(() => { + if (caps.forceNoAnim || caps.forceStableUi) return; + if (state === "idle" && caps.os === "termux") return; + const id = setInterval(() => setTick((n) => n + 1), SPEED_MS[state]); + return () => clearInterval(id); + }, [caps, state]); + + const frame = frames[tick % frames.length] ?? frames[0] ?? ""; + const color = + state === "error" + ? theme.error + : state === "success" + ? theme.success + : state === "waiting" + ? theme.muted + : theme.accent; + + return ( + + + {frame} + + {label ? ( + + {" "} + {label} + + ) : null} + + ); +}; + +/** Pure helper for tests — the frame array selected for a state and tier. */ +export function selectFrames( + caps: TerminalCapabilities, + state: AgentState, +): string[] { + const useUnicode = caps.unicodeWidth !== "narrow" && caps.colorDepth >= 4; + return useUnicode ? CORE_UNICODE[state] : CORE_ASCII[state]; +} diff --git a/src/tui/PromptV2.tsx b/src/tui/PromptV2.tsx new file mode 100644 index 0000000..e15e01f --- /dev/null +++ b/src/tui/PromptV2.tsx @@ -0,0 +1,397 @@ +/** + * Prompt v2 — multiline-capable input with history recall and paste detection. + * + * Behavior: + * • Single line by default; Shift+Enter (or Ctrl+J / Esc-then-Enter) inserts + * a newline; Enter submits. + * • Up / Down arrows recall previous submissions when the buffer is empty + * OR cursor is on the first/last line. + * • Ctrl+U clears the line, Ctrl+W deletes the previous word. + * • Auto-detects bracketed-paste sequences and inserts text verbatim, even + * when it contains newlines, without firing on every keystroke. + * + * The component is self-contained (uses ink's `useInput`) so it has full + * control over the keymap. We don't rely on `ink-text-input` for v2 because + * its single-line model fights every multiline feature we want. + */ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Box, Text, useInput } from "ink"; +import type { Theme } from "../types.js"; +import { COMMANDS } from "../commands/index.js"; +import type { InputHistory } from "../lib/inputHistory.js"; + +export interface PromptV2Props { + theme: Theme; + value: string; + onChange(value: string): void; + onSubmit(value: string): void; + history?: InputHistory; + disabled?: boolean; + placeholder?: string; + /** Disable cursor blink — for terminals that flicker. */ + stable?: boolean; +} + +interface CursorPos { + line: number; + col: number; +} + +export const PromptV2: React.FC = ({ + theme, + value, + onChange, + onSubmit, + history, + disabled, + placeholder, + stable = false, +}) => { + const lines = useMemo(() => value.split("\n"), [value]); + const [cursor, setCursor] = useState({ + line: lines.length - 1, + col: lines[lines.length - 1]!.length, + }); + const [historyIdx, setHistoryIdx] = useState(-1); + const [draft, setDraft] = useState(""); + const [caretOn, setCaretOn] = useState(true); + + // Reset cursor when value is cleared externally (after submit). + const lastValueRef = useRef(value); + useEffect(() => { + if (value !== lastValueRef.current) { + lastValueRef.current = value; + const ls = value.split("\n"); + setCursor({ line: ls.length - 1, col: ls[ls.length - 1]!.length }); + if (value === "") setHistoryIdx(-1); + } + }, [value]); + + // Cursor blink. + useEffect(() => { + if (stable || disabled) { + setCaretOn(true); + return; + } + const id = setInterval(() => setCaretOn((c) => !c), 500); + return () => clearInterval(id); + }, [stable, disabled]); + + const setBoth = (next: string, pos?: CursorPos) => { + onChange(next); + if (pos) setCursor(pos); + }; + + useInput( + (input, key) => { + if (disabled) return; + + // ── Paste burst detection ─────────────────────────────────────────── + // Ink delivers pasted text as a single `input` chunk. If we receive + // >1 char and no special key flags, treat it as a paste — newlines + // get embedded literally, alignment stays sane. + if (input && input.length > 1 && !key.ctrl && !key.meta) { + insertText(input); + return; + } + + if (key.escape) { + // Don't swallow Esc — App handles cancellation. We just no-op. + return; + } + + if (key.return) { + // Shift+Enter (or any modifier) inserts newline; bare Enter submits. + if (key.shift || key.ctrl || key.meta) { + insertText("\n"); + return; + } + if (value.trim().length === 0) return; + onSubmit(value); + setHistoryIdx(-1); + setDraft(""); + return; + } + + if (key.upArrow) { + if (canRecallHistory("up")) { + recallHistory(1); + return; + } + moveCursor(-1, 0); + return; + } + if (key.downArrow) { + if (canRecallHistory("down")) { + recallHistory(-1); + return; + } + moveCursor(1, 0); + return; + } + if (key.leftArrow) { + moveCursor(0, -1); + return; + } + if (key.rightArrow) { + moveCursor(0, 1); + return; + } + + if (key.backspace || key.delete) { + deleteBack(key.delete && !key.backspace); + return; + } + + if (key.tab) { + // First-token tab → autocomplete the slash command. + const sug = autocompleteCommand(value); + if (sug && !value.includes(" ")) { + setBoth(`/${sug} `, undefined); + return; + } + // Else insert a literal two-space soft tab (real \t breaks alignment). + insertText(" "); + return; + } + + if (key.ctrl && input === "u") { + // Ctrl+U: kill current line. + const ls = value.split("\n"); + ls[cursor.line] = ""; + const next = ls.join("\n"); + setBoth(next, { line: cursor.line, col: 0 }); + return; + } + if (key.ctrl && input === "w") { + deleteWordBack(); + return; + } + if (key.ctrl && input === "a") { + setCursor({ line: cursor.line, col: 0 }); + return; + } + if (key.ctrl && input === "e") { + setCursor({ + line: cursor.line, + col: (lines[cursor.line] ?? "").length, + }); + return; + } + if (key.ctrl && input === "k") { + // Kill to end of line. + const ls = value.split("\n"); + ls[cursor.line] = (ls[cursor.line] ?? "").slice(0, cursor.col); + setBoth(ls.join("\n")); + return; + } + + if (input && !key.ctrl && !key.meta) { + insertText(input); + } + }, + { isActive: !disabled }, + ); + + function insertText(text: string): void { + const before = value.slice(0, offsetFor(cursor)); + const after = value.slice(offsetFor(cursor)); + const next = before + text + after; + const newPos = posFromOffset(next, before.length + text.length); + setBoth(next, newPos); + } + + function deleteBack(forward = false): void { + const off = offsetFor(cursor); + if (!forward && off === 0) return; + if (forward && off === value.length) return; + const next = forward + ? value.slice(0, off) + value.slice(off + 1) + : value.slice(0, off - 1) + value.slice(off); + const newPos = posFromOffset(next, forward ? off : off - 1); + setBoth(next, newPos); + } + + function deleteWordBack(): void { + const off = offsetFor(cursor); + if (off === 0) return; + let i = off - 1; + // skip trailing spaces + while (i > 0 && /\s/.test(value[i]!)) i--; + while (i > 0 && !/\s/.test(value[i - 1]!)) i--; + const next = value.slice(0, i) + value.slice(off); + setBoth(next, posFromOffset(next, i)); + } + + function moveCursor(dy: number, dx: number): void { + const ls = value.split("\n"); + let { line, col } = cursor; + if (dy !== 0) { + line = clamp(line + dy, 0, ls.length - 1); + col = clamp(col, 0, ls[line]!.length); + } + if (dx !== 0) { + col += dx; + if (col < 0) { + if (line > 0) { + line -= 1; + col = ls[line]!.length; + } else { + col = 0; + } + } else if (col > ls[line]!.length) { + if (line < ls.length - 1) { + line += 1; + col = 0; + } else { + col = ls[line]!.length; + } + } + } + setCursor({ line, col }); + } + + function canRecallHistory(dir: "up" | "down"): boolean { + if (!history) return false; + const ls = value.split("\n"); + if (ls.length === 1) return true; + if (dir === "up" && cursor.line === 0) return true; + if (dir === "down" && cursor.line === ls.length - 1) return true; + return false; + } + + function recallHistory(step: number): void { + if (!history) return; + if (historyIdx === -1 && step > 0) { + // Save the current draft so Down can restore it. + setDraft(value); + } + const total = history.size(); + if (total === 0) return; + let next = historyIdx + step; + if (next >= total) next = total - 1; + if (next < -1) next = -1; + if (next === -1) { + onChange(draft); + const ls = draft.split("\n"); + setCursor({ line: ls.length - 1, col: ls[ls.length - 1]!.length }); + } else { + const entry = history.recall(next); + if (!entry) return; + onChange(entry.text); + const ls = entry.text.split("\n"); + setCursor({ line: ls.length - 1, col: ls[ls.length - 1]!.length }); + } + setHistoryIdx(next); + } + + function offsetFor(pos: CursorPos): number { + const ls = value.split("\n"); + let off = 0; + for (let i = 0; i < pos.line; i++) off += ls[i]!.length + 1; + return off + pos.col; + } + + const sug = autocompleteCommand(value); + const isMultiline = lines.length > 1; + const renderedLines = lines.map((ln, idx) => { + const isCursorLine = idx === cursor.line && !disabled; + if (!isCursorLine) return ln || " "; + const before = ln.slice(0, cursor.col); + const at = ln.slice(cursor.col, cursor.col + 1) || " "; + const after = ln.slice(cursor.col + 1); + return { before, at, after }; + }); + + return ( + + {renderedLines.map((rl, i) => ( + + {i === 0 ? ( + + {disabled ? "·" : caretOn ? "❯" : " "}{" "} + + ) : ( + {" "} + )} + {disabled && i === 0 ? ( + {placeholder ?? "Working… (Esc to cancel)"} + ) : typeof rl === "string" ? ( + {rl} + ) : ( + <> + {rl.before} + {rl.at} + {rl.after} + + )} + + ))} + {!disabled && value.length === 0 ? ( + + + {placeholder ?? "Type a message · /help · Shift+Enter for newline · ↑/↓ history"} + + + ) : null} + {!disabled && sug && !isMultiline ? ( + + ↪ {sug} + + ) : null} + + ); +}; + +// ── Helpers exported for unit tests ────────────────────────────────────── + +export function posFromOffset(text: string, offset: number): CursorPos { + let off = clamp(offset, 0, text.length); + let line = 0; + for (let i = 0; i < text.length && off > 0; i++) { + if (text[i] === "\n") { + line++; + off--; + continue; + } + if (off === 0) break; + off--; + } + // recompute col + const ls = text.split("\n"); + const col = clamp(offset, 0, text.length); + let consumed = 0; + for (let i = 0; i < line; i++) consumed += ls[i]!.length + 1; + return { line, col: col - consumed }; +} + +export function clamp(n: number, lo: number, hi: number): number { + if (n < lo) return lo; + if (n > hi) return hi; + return n; +} + +export function autocompleteCommand(value: string): string | undefined { + if (!value.startsWith("/") || value.includes(" ") || value.includes("\n")) + return undefined; + const partial = value.slice(1).toLowerCase(); + if (!partial) { + return COMMANDS.filter((c) => !c.hidden) + .map((c) => `/${c.name}`) + .slice(0, 6) + .join(" "); + } + const matches = COMMANDS.filter( + (c) => + !c.hidden && + (c.name.startsWith(partial) || + (c.aliases ?? []).some((a) => a.startsWith(partial))), + ); + if (matches.length === 0) return undefined; + return matches.map((c) => `/${c.name} — ${c.description}`).slice(0, 4).join("\n "); +} diff --git a/src/types.ts b/src/types.ts index 0026020..d87c652 100644 --- a/src/types.ts +++ b/src/types.ts @@ -131,6 +131,19 @@ export interface AppStateSnapshot { pendingImages: ChatImage[]; } +export type UiMode = "normal" | "compact" | "focus" | "debug" | "noanim"; + +export interface McpActions { + statusReport(): string; + toolsReport(server?: string): string; + restart(server: string): Promise; + setPermission(server: string, permission: "auto" | "confirm" | "deny"): string; +} + +export interface SandboxActions { + statusReport(): string; +} + export interface AppActions { setProvider(id: string): void; setModel(id: string): void; @@ -154,6 +167,12 @@ export interface AppActions { attachImage(image: ChatImage): void; /** Wipe all pending image attachments. */ clearAttachedImages(): void; + /** Switch the active display mode (v0.2). */ + setUiMode?(mode: UiMode): void; + /** Optional bridge to the MCP manager (only present when MCP is enabled). */ + mcp?: McpActions; + /** Optional bridge to the sandbox policy (always present when policy loaded). */ + sandbox?: SandboxActions; } export interface SkillManifest { @@ -179,4 +198,12 @@ export interface Theme { muted: string; error: string; success: string; + /** Optional v0.2 fields — themes can leave these unset to inherit from base colors. */ + warn?: string; + tool?: string; + command?: string; + diffAdd?: string; + diffDel?: string; + /** When true, all colors should be treated as muted (no bright/bold output). */ + noColor?: boolean; } diff --git a/src/ui/Message.tsx b/src/ui/Message.tsx index 18ce6fe..9678c6b 100644 --- a/src/ui/Message.tsx +++ b/src/ui/Message.tsx @@ -2,17 +2,27 @@ * One transcript bubble. We keep it small + decoupled so the App can render * a flat list of 's without measuring layout. * - * While the assistant is streaming AND currently inside a fenced code block, - * we render a CodeWritingIndicator under the sigil so the user gets a clear - * "the model is writing code" visual. + * v0.2 adds variants for tool calls, errors, warnings, success, command + * output, and inline unified diffs. These get distinct sigils + colors but + * still flow with the rest of the transcript so streaming order stays sane. */ import React from "react"; import { Box, Text } from "ink"; import type { Theme } from "../types.js"; import { renderMarkdown } from "../lib/markdown.js"; import { CodeWritingIndicator, detectActiveCode } from "./CodeWritingIndicator.js"; +import { DiffView } from "../tui/DiffView.js"; -export type BubbleRole = "user" | "assistant" | "system"; +export type BubbleRole = + | "user" + | "assistant" + | "system" + | "tool" + | "error" + | "warn" + | "success" + | "command" + | "diff"; interface Props { theme: Theme; @@ -28,8 +38,39 @@ const SIGIL: Record = { user: "▎ you", assistant: "▍ FastCode", system: "✦ system", + tool: "⚙ tool", + error: "✖ error", + warn: "⚠ warn", + success: "✓ ok", + command: "$ shell", + diff: "± diff", }; +function colorFor(theme: Theme, role: BubbleRole): string { + switch (role) { + case "user": + return theme.user; + case "assistant": + return theme.assistant; + case "system": + return theme.system; + case "tool": + return theme.tool ?? theme.accent; + case "error": + return theme.error; + case "warn": + return theme.warn ?? theme.error; + case "success": + return theme.success; + case "command": + return theme.command ?? theme.accent; + case "diff": + return theme.muted; + default: + return theme.assistant; + } +} + export const MessageBubble: React.FC = ({ theme, role, @@ -37,14 +78,15 @@ export const MessageBubble: React.FC = ({ streaming, markdown, }) => { - const color = - role === "user" ? theme.user : role === "assistant" ? theme.assistant : theme.system; - const display = markdown ? renderMarkdown(content) : content; + const color = colorFor(theme, role); + const useMarkdown = markdown && role !== "command" && role !== "diff"; + const display = useMarkdown ? renderMarkdown(content) : content; const activeCode = streaming && role === "assistant" ? detectActiveCode(content) : null; + return ( - + {SIGIL[role]} {streaming ? ( @@ -55,10 +97,16 @@ export const MessageBubble: React.FC = ({ {activeCode ? : null} - - {display || (streaming ? "…" : "")} - {streaming && display ? : null} - + {role === "diff" ? ( + + ) : role === "command" ? ( + {content} + ) : ( + + {display || (streaming ? "…" : "")} + {streaming && display ? : null} + + )} ); diff --git a/test/browserText.test.ts b/test/browserText.test.ts new file mode 100644 index 0000000..9a0ef3a --- /dev/null +++ b/test/browserText.test.ts @@ -0,0 +1,23 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { createTextEngine } from "../src/browser/text.js"; +import { isHostAllowed } from "../src/sandbox/policy.js"; + +describe("createTextEngine", () => { + it("invokes the policy hook before fetching", async () => { + const seen: string[] = []; + const engine = createTextEngine({ + assertHostAllowed(host) { + seen.push(host); + throw new Error(`denied: ${host}`); + }, + }); + await assert.rejects(() => engine.fetch("https://evil.example/x")); + assert.equal(seen[0], "evil.example"); + }); + + it("respects egress allowlist semantics from sandbox policy", () => { + assert.equal(isHostAllowed("api.openai.com", ["*.openai.com"]), true); + assert.equal(isHostAllowed("evil.example", ["api.openai.com"]), false); + }); +}); diff --git a/test/diffView.test.ts b/test/diffView.test.ts new file mode 100644 index 0000000..615d744 --- /dev/null +++ b/test/diffView.test.ts @@ -0,0 +1,45 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { parseDiff } from "../src/tui/DiffView.js"; + +const SAMPLE = `diff --git a/foo.ts b/foo.ts +index 1234..5678 100644 +--- a/foo.ts ++++ b/foo.ts +@@ -1,3 +1,4 @@ + keep this +-delete me ++add me ++also added + trailing context`; + +describe("parseDiff", () => { + it("classifies meta, header, hunk, add, del, context", () => { + const lines = parseDiff(SAMPLE); + const kinds = lines.map((l) => l.kind); + assert.deepEqual(kinds, [ + "meta", + "meta", + "header", + "header", + "hunk", + "context", + "del", + "add", + "add", + "context", + ]); + }); + + it("preserves +++/--- as headers, not as add/del", () => { + const lines = parseDiff("--- a/x\n+++ b/x"); + assert.equal(lines[0]?.kind, "header"); + assert.equal(lines[1]?.kind, "header"); + }); + + it("emits empty context line for blank input lines", () => { + const lines = parseDiff("\n"); + assert.equal(lines[0]?.kind, "context"); + assert.equal(lines[0]?.text, ""); + }); +}); diff --git a/test/errorCatalog.test.ts b/test/errorCatalog.test.ts new file mode 100644 index 0000000..c996c0d --- /dev/null +++ b/test/errorCatalog.test.ts @@ -0,0 +1,62 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { explainError, formatFriendlyError } from "../src/lib/errorCatalog.js"; + +describe("explainError", () => { + it("classifies ENOENT as FS_NOT_FOUND", () => { + const e = explainError(new Error("ENOENT: no such file, open 'foo'"), { path: "foo" }); + assert.equal(e.code, "FS_NOT_FOUND"); + assert.match(e.hint!, /workspace/); + }); + + it("classifies EACCES as FS_DENIED", () => { + const e = explainError(new Error("EACCES: permission denied")); + assert.equal(e.code, "FS_DENIED"); + }); + + it("classifies ETIMEDOUT as NET_TIMEOUT", () => { + const e = explainError(new Error("ETIMEDOUT"), { url: "https://example.com" }); + assert.equal(e.code, "NET_TIMEOUT"); + assert.match(e.hint!, /example\.com/); + }); + + it("classifies ENOTFOUND as NET_DNS", () => { + const e = explainError(new Error("getaddrinfo ENOTFOUND nope.example")); + assert.equal(e.code, "NET_DNS"); + }); + + it("classifies SANDBOX_DENY_PATH errors", () => { + const e = explainError(new Error("SANDBOX_DENY_PATH: /etc/shadow")); + assert.equal(e.code, "SANDBOX_DENIED"); + assert.match(e.hint!, /policy\.json/); + }); + + it("classifies AbortError as ABORTED", () => { + const err = new Error("aborted"); + err.name = "AbortError"; + const e = explainError(err); + assert.equal(e.code, "ABORTED"); + }); + + it("classifies 401 as AUTH_FAILED", () => { + const e = explainError(new Error("HTTP 401 Unauthorized")); + assert.equal(e.code, "AUTH_FAILED"); + }); + + it("classifies 429 as RATE_LIMITED", () => { + const e = explainError(new Error("HTTP 429 rate limit exceeded")); + assert.equal(e.code, "RATE_LIMITED"); + }); + + it("falls back to UNKNOWN for unclassified errors", () => { + const e = explainError(new Error("something completely unexpected")); + assert.equal(e.code, "UNKNOWN"); + }); + + it("formatFriendlyError includes title and code in the output", () => { + const e = explainError(new Error("ENOENT"), { path: "foo" }); + const formatted = formatFriendlyError(e); + assert.match(formatted, /\*\*File not found\*\*/); + assert.match(formatted, /FS_NOT_FOUND/); + }); +}); diff --git a/test/eventBus.test.ts b/test/eventBus.test.ts new file mode 100644 index 0000000..df63953 --- /dev/null +++ b/test/eventBus.test.ts @@ -0,0 +1,50 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { EventBus } from "../src/core/EventBus.js"; + +describe("EventBus", () => { + it("delivers events to subscribers", () => { + const bus = new EventBus(); + const seen: string[] = []; + bus.on("chat:user-message", (p) => seen.push(p.text)); + bus.emit("chat:user-message", { text: "hello" }); + bus.emit("chat:user-message", { text: "world" }); + assert.deepEqual(seen, ["hello", "world"]); + }); + + it("returns an unsubscribe function", () => { + const bus = new EventBus(); + let count = 0; + const off = bus.on("input:cancel", () => { + count += 1; + }); + bus.emit("input:cancel", undefined); + off(); + bus.emit("input:cancel", undefined); + assert.equal(count, 1); + assert.equal(bus.listenerCount("input:cancel"), 0); + }); + + it("isolates throwing listeners from siblings", () => { + const bus = new EventBus(); + let reached = false; + bus.on("app:ready", () => { + throw new Error("boom"); + }); + bus.on("app:ready", () => { + reached = true; + }); + // Suppress noise: stderr.write is mocked-light, we just don't assert on it. + bus.emit("app:ready", undefined); + assert.equal(reached, true); + }); + + it("clears all listeners", () => { + const bus = new EventBus(); + bus.on("session:saved", () => {}); + bus.on("session:saved", () => {}); + assert.equal(bus.listenerCount("session:saved"), 2); + bus.clear(); + assert.equal(bus.listenerCount("session:saved"), 0); + }); +}); diff --git a/test/fixtures/echo-mcp-server.mjs b/test/fixtures/echo-mcp-server.mjs new file mode 100644 index 0000000..f3c2c0a --- /dev/null +++ b/test/fixtures/echo-mcp-server.mjs @@ -0,0 +1,78 @@ +// Minimal MCP-style stdio server for tests. +// Implements just enough of JSON-RPC 2.0 + the methods FastCode actually uses: +// - initialize +// - tools/list (returns one tool: "echo") +// - tools/call (echoes input back wrapped in MCP content blocks) + +import readline from "node:readline"; + +const rl = readline.createInterface({ input: process.stdin }); + +function send(obj) { + process.stdout.write(`${JSON.stringify(obj)}\n`); +} + +const TOOLS = [ + { + name: "echo", + description: "Echo back the supplied text", + inputSchema: { + type: "object", + properties: { text: { type: "string" } }, + required: ["text"], + }, + }, +]; + +rl.on("line", (line) => { + const trimmed = line.trim(); + if (!trimmed) return; + let req; + try { + req = JSON.parse(trimmed); + } catch { + return; + } + if (typeof req.id !== "number") return; + + if (req.method === "initialize") { + send({ + jsonrpc: "2.0", + id: req.id, + result: { + protocolVersion: "2024-11-05", + serverInfo: { name: "echo", version: "0.0.1" }, + capabilities: { tools: {} }, + }, + }); + return; + } + if (req.method === "tools/list") { + send({ jsonrpc: "2.0", id: req.id, result: { tools: TOOLS } }); + return; + } + if (req.method === "tools/call") { + const args = req.params?.arguments ?? {}; + if (req.params?.name === "echo") { + send({ + jsonrpc: "2.0", + id: req.id, + result: { + content: [{ type: "text", text: String(args.text ?? "") }], + }, + }); + return; + } + send({ + jsonrpc: "2.0", + id: req.id, + error: { code: -32601, message: `unknown tool: ${req.params?.name}` }, + }); + return; + } + send({ + jsonrpc: "2.0", + id: req.id, + error: { code: -32601, message: `unknown method: ${req.method}` }, + }); +}); diff --git a/test/headlessEngine.test.ts b/test/headlessEngine.test.ts new file mode 100644 index 0000000..b7f81e0 --- /dev/null +++ b/test/headlessEngine.test.ts @@ -0,0 +1,39 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + isHeadlessAvailable, + loadBrowserEngineDetailed, + HeadlessEngineUnavailableError, +} from "../src/browser/index.js"; + +// playwright-core is NOT a dependency of FastCode — these tests confirm +// that the module gracefully falls back to the text engine when it's missing. + +describe("browser engine selection", () => { + it("isHeadlessAvailable returns false when playwright-core is not installed", async () => { + const ok = await isHeadlessAvailable(); + assert.equal(ok, false); + }); + + it("loadBrowserEngineDetailed(prefer=text) returns the text engine", async () => { + const r = await loadBrowserEngineDetailed({ prefer: "text" }); + assert.equal(r.kind, "text"); + }); + + it("loadBrowserEngineDetailed(prefer=auto) falls back to text", async () => { + const r = await loadBrowserEngineDetailed({ prefer: "auto" }); + assert.equal(r.kind, "text"); + }); + + it("loadBrowserEngineDetailed(prefer=headless, strict) throws when unavailable", async () => { + await assert.rejects( + () => loadBrowserEngineDetailed({ prefer: "headless", strict: true }), + HeadlessEngineUnavailableError, + ); + }); + + it("loadBrowserEngineDetailed(prefer=headless, !strict) silently falls back", async () => { + const r = await loadBrowserEngineDetailed({ prefer: "headless" }); + assert.equal(r.kind, "text"); + }); +}); diff --git a/test/inputHistory.test.ts b/test/inputHistory.test.ts new file mode 100644 index 0000000..5738d9b --- /dev/null +++ b/test/inputHistory.test.ts @@ -0,0 +1,71 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, existsSync, readFileSync, appendFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { openHistory } from "../src/lib/inputHistory.js"; + +describe("openHistory", () => { + let dir = ""; + before(() => { + dir = mkdtempSync(join(tmpdir(), "fastcode-hist-")); + }); + after(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("creates file on first push and persists JSONL", () => { + const path = join(dir, "h1"); + const h = openHistory({ path }); + h.push("hello"); + h.push("world"); + assert.equal(h.size(), 2); + assert.ok(existsSync(path)); + const lines = readFileSync(path, "utf8").trim().split("\n"); + assert.equal(lines.length, 2); + const last = JSON.parse(lines[1]!); + assert.equal(last.text, "world"); + }); + + it("recall(0) returns the most recent entry", () => { + const path = join(dir, "h2"); + const h = openHistory({ path }); + h.push("first"); + h.push("second"); + h.push("third"); + assert.equal(h.recall(0)?.text, "third"); + assert.equal(h.recall(1)?.text, "second"); + assert.equal(h.recall(2)?.text, "first"); + assert.equal(h.recall(3), null); + }); + + it("skips empty and exact duplicate entries", () => { + const path = join(dir, "h3"); + const h = openHistory({ path }); + h.push(""); + h.push("dup"); + h.push("dup"); + h.push("dup2"); + assert.equal(h.size(), 2); + }); + + it("survives a corrupt line in the file", () => { + const path = join(dir, "h4"); + const h = openHistory({ path }); + h.push("ok"); + appendFileSync(path, "{not valid json}\n", "utf8"); + appendFileSync(path, JSON.stringify({ ts: 1, text: "later" }) + "\n", "utf8"); + const h2 = openHistory({ path }); + assert.equal(h2.size(), 2); + assert.equal(h2.recall(0)?.text, "later"); + }); + + it("trims to max entries", () => { + const path = join(dir, "h5"); + const h = openHistory({ path, max: 10 }); + for (let i = 0; i < 25; i++) h.push(`x${i}`); + assert.equal(h.size(), 10); + assert.equal(h.recall(0)?.text, "x24"); + assert.equal(h.recall(9)?.text, "x15"); + }); +}); diff --git a/test/logoAnimator.test.ts b/test/logoAnimator.test.ts new file mode 100644 index 0000000..afa82e1 --- /dev/null +++ b/test/logoAnimator.test.ts @@ -0,0 +1,33 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { selectFrames } from "../src/tui/LogoAnimator.js"; +import { detectCapabilities } from "../src/platform/TerminalCapabilities.js"; + +describe("LogoAnimator.selectFrames", () => { + it("uses unicode frames when terminal supports it", () => { + const caps = detectCapabilities({ + LANG: "en_US.UTF-8", + COLORTERM: "truecolor", + }); + const frames = selectFrames(caps, "thinking"); + assert.ok(frames.length > 0); + assert.ok(frames[0]!.includes("▰") || frames[0]!.includes("▱")); + }); + + it("falls back to ASCII when unicode is unavailable", () => { + const caps = detectCapabilities({ NO_COLOR: "1" }); + const frames = selectFrames(caps, "executing"); + assert.ok(frames.length > 0); + // No frame should contain Unicode block-drawing characters. + for (const f of frames) { + assert.doesNotMatch(f, /[\u2580-\u259F\u25A0-\u25FF\u25B0-\u25BF]/); + } + }); + + it("returns at least one frame for every state", () => { + const caps = detectCapabilities({ LANG: "en_US.UTF-8" }); + for (const s of ["idle", "thinking", "executing", "waiting", "success", "error"] as const) { + assert.ok(selectFrames(caps, s).length > 0, `${s} has frames`); + } + }); +}); diff --git a/test/mcpClient.test.ts b/test/mcpClient.test.ts new file mode 100644 index 0000000..6ab39a5 --- /dev/null +++ b/test/mcpClient.test.ts @@ -0,0 +1,43 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { MCPClient } from "../src/mcp/client.js"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SERVER = join(HERE, "fixtures", "echo-mcp-server.mjs"); + +describe("MCPClient (stdio echo server)", () => { + it("initializes, lists tools, and calls echo end-to-end", async () => { + const client = new MCPClient( + "echo", + { transport: "stdio", command: process.execPath, args: [SERVER] }, + 5000, + ); + try { + await client.start(); + const tools = await client.listTools(); + assert.equal(tools.length, 1); + assert.equal(tools[0]?.name, "echo"); + const result = await client.callTool("echo", { text: "hello" }); + assert.equal(result.isError, undefined); + assert.deepEqual(result.content, [{ type: "text", text: "hello" }]); + } finally { + await client.stop(); + } + }); + + it("rejects callTool on a non-existent server tool", async () => { + const client = new MCPClient( + "echo", + { transport: "stdio", command: process.execPath, args: [SERVER] }, + 5000, + ); + try { + await client.start(); + await assert.rejects(() => client.callTool("nope", {})); + } finally { + await client.stop(); + } + }); +}); diff --git a/test/mcpConfig.test.ts b/test/mcpConfig.test.ts new file mode 100644 index 0000000..a578a53 --- /dev/null +++ b/test/mcpConfig.test.ts @@ -0,0 +1,66 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadMcpConfig, interpolate } from "../src/mcp/config.js"; + +describe("loadMcpConfig", () => { + it("returns empty defaults when no file exists", () => { + const dir = mkdtempSync(join(tmpdir(), "fastcode-mcp-")); + try { + const cfg = loadMcpConfig({ workspace: dir, env: {} }); + assert.deepEqual(cfg.servers, {}); + assert.equal(cfg.defaults.timeoutMs, 30_000); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("loads project-level mcp.json with stdio server", () => { + const dir = mkdtempSync(join(tmpdir(), "fastcode-mcp-")); + mkdirSync(join(dir, ".fastcode")); + writeFileSync( + join(dir, ".fastcode", "mcp.json"), + JSON.stringify({ + servers: { + fs: { + transport: "stdio", + command: "node", + args: ["${workspace}/server.js"], + env: { TOKEN: "${env:MY_TOKEN}" }, + permission: "confirm", + tags: ["network"], + }, + }, + defaults: { timeoutMs: 5000 }, + }), + ); + try { + const cfg = loadMcpConfig({ workspace: dir, env: { MY_TOKEN: "abc" } }); + assert.equal(cfg.defaults.timeoutMs, 5000); + const fs = cfg.servers.fs!; + assert.equal(fs.transport, "stdio"); + assert.equal(fs.command, "node"); + assert.deepEqual(fs.args, [`${dir}/server.js`]); + assert.deepEqual(fs.env, { TOKEN: "abc" }); + assert.equal(fs.permission, "confirm"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("interpolate", () => { + it("replaces ${workspace} and ${env:NAME}", () => { + const out = interpolate("X=${workspace}, T=${env:T}", { + workspace: "/tmp/ws", + env: { T: "42" }, + }); + assert.equal(out, "X=/tmp/ws, T=42"); + }); + it("leaves unknown tokens empty", () => { + const out = interpolate("${env:UNDEFINED}", { workspace: "/", env: {} }); + assert.equal(out, ""); + }); +}); diff --git a/test/mcpRegistry.test.ts b/test/mcpRegistry.test.ts new file mode 100644 index 0000000..75a3d87 --- /dev/null +++ b/test/mcpRegistry.test.ts @@ -0,0 +1,45 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { MCPToolRegistry } from "../src/mcp/registry.js"; + +describe("MCPToolRegistry", () => { + it("registers and lists tools per server", () => { + const reg = new MCPToolRegistry(); + reg.set("fs", [{ name: "read" }, { name: "write" }]); + reg.set("github", [{ name: "list_issues" }]); + const all = reg.list(); + assert.equal(all.length, 3); + const fsTools = reg.listForServer("fs"); + assert.equal(fsTools.length, 2); + }); + + it("resolves fully qualified tool names", () => { + const reg = new MCPToolRegistry(); + reg.set("fs", [{ name: "read" }]); + reg.set("github", [{ name: "read" }]); + const e = reg.resolve("github.read"); + assert.equal(e?.server, "github"); + }); + + it("resolves bare names if unique", () => { + const reg = new MCPToolRegistry(); + reg.set("fs", [{ name: "read" }]); + reg.set("github", [{ name: "list_issues" }]); + const e = reg.resolve("list_issues"); + assert.equal(e?.server, "github"); + }); + + it("returns null when bare name is ambiguous", () => { + const reg = new MCPToolRegistry(); + reg.set("fs", [{ name: "read" }]); + reg.set("github", [{ name: "read" }]); + assert.equal(reg.resolve("read"), null); + }); + + it("removes a server", () => { + const reg = new MCPToolRegistry(); + reg.set("fs", [{ name: "read" }]); + reg.remove("fs"); + assert.equal(reg.list().length, 0); + }); +}); diff --git a/test/permission.test.ts b/test/permission.test.ts new file mode 100644 index 0000000..0c7b9d6 --- /dev/null +++ b/test/permission.test.ts @@ -0,0 +1,40 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { MCPPermissionManager } from "../src/mcp/permission.js"; + +describe("MCPPermissionManager", () => { + it("classifies read-style tools as low risk and allows under auto", () => { + const pm = new MCPPermissionManager(); + const r = pm.classify("fs", "read", { transport: "stdio", permission: "auto" }); + assert.equal(r.risk, "low"); + assert.equal(r.decision, "allow"); + }); + + it("escalates write-style tool names to medium", () => { + const pm = new MCPPermissionManager(); + const r = pm.classify("fs", "write_file", { transport: "stdio", permission: "auto" }); + assert.equal(r.risk, "med"); + assert.equal(r.decision, "allow"); + }); + + it("forces high risk for destructive names and asks", () => { + const pm = new MCPPermissionManager(); + const r = pm.classify("fs", "delete_file", { transport: "stdio", permission: "auto" }); + assert.equal(r.risk, "high"); + assert.equal(r.decision, "ask"); + }); + + it("respects per-server override", () => { + const pm = new MCPPermissionManager(); + pm.setServerPermission("github", "deny"); + const r = pm.classify("github", "read_repo", { transport: "stdio" }); + assert.equal(r.decision, "deny"); + }); + + it("respects per-tool override", () => { + const pm = new MCPPermissionManager(); + pm.setToolPermission("github", "create_pr", "confirm"); + const r = pm.classify("github", "create_pr", { transport: "stdio", permission: "auto" }); + assert.equal(r.decision, "ask"); + }); +}); diff --git a/test/promptHelpers.test.ts b/test/promptHelpers.test.ts new file mode 100644 index 0000000..182144c --- /dev/null +++ b/test/promptHelpers.test.ts @@ -0,0 +1,41 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + clamp, + posFromOffset, + autocompleteCommand, +} from "../src/tui/PromptV2.js"; + +describe("PromptV2 helpers", () => { + it("clamp constrains to [lo, hi]", () => { + assert.equal(clamp(5, 0, 10), 5); + assert.equal(clamp(-1, 0, 10), 0); + assert.equal(clamp(11, 0, 10), 10); + }); + + it("posFromOffset locates line/col on multiline text", () => { + const text = "abc\ndef\nghij"; + assert.deepEqual(posFromOffset(text, 0), { line: 0, col: 0 }); + assert.deepEqual(posFromOffset(text, 3), { line: 0, col: 3 }); + assert.deepEqual(posFromOffset(text, 4), { line: 1, col: 0 }); + assert.deepEqual(posFromOffset(text, 7), { line: 1, col: 3 }); + assert.deepEqual(posFromOffset(text, 8), { line: 2, col: 0 }); + assert.deepEqual(posFromOffset(text, 12), { line: 2, col: 4 }); + }); + + it("autocompleteCommand suggests root commands when buffer is just '/'", () => { + const sug = autocompleteCommand("/"); + assert.ok(sug && sug.includes("/help")); + }); + + it("autocompleteCommand returns the matching command for partial slash", () => { + const sug = autocompleteCommand("/he"); + assert.ok(sug && sug.startsWith("/help")); + }); + + it("autocompleteCommand returns undefined when there's whitespace or newline", () => { + assert.equal(autocompleteCommand("/help arg"), undefined); + assert.equal(autocompleteCommand("/help\n"), undefined); + assert.equal(autocompleteCommand("hello"), undefined); + }); +}); diff --git a/test/sandboxPath.test.ts b/test/sandboxPath.test.ts new file mode 100644 index 0000000..1f1e069 --- /dev/null +++ b/test/sandboxPath.test.ts @@ -0,0 +1,82 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PathGuard, globToRegex } from "../src/sandbox/PathGuard.js"; +import { SandboxError } from "../src/sandbox/errors.js"; +import { isHostAllowed, assertShellAllowed, loadPolicy } from "../src/sandbox/policy.js"; + +describe("PathGuard", () => { + let root = ""; + before(() => { + root = mkdtempSync(join(tmpdir(), "fastcode-pg-")); + mkdirSync(join(root, "sub"), { recursive: true }); + writeFileSync(join(root, "sub", "ok.txt"), "ok"); + writeFileSync(join(root, ".env"), "secret=1"); + }); + after(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it("accepts paths inside the root", () => { + const guard = new PathGuard(root); + const abs = guard.check("sub/ok.txt"); + assert.equal(abs, join(root, "sub", "ok.txt")); + }); + + it("rejects paths that escape via ..", () => { + const guard = new PathGuard(root); + assert.throws(() => guard.check("../etc/passwd"), SandboxError); + }); + + it("rejects absolute paths outside root", () => { + const guard = new PathGuard(root); + assert.throws(() => guard.check("/etc/passwd"), SandboxError); + }); + + it("denies globbed paths", () => { + const guard = new PathGuard(root, { denyGlobs: ["**/.env"] }); + assert.throws(() => guard.check(".env"), SandboxError); + }); + + it("globToRegex matches simple patterns", () => { + assert.match("foo/bar.env", globToRegex("**/*.env")); + assert.match(".env", globToRegex("**/.env")); + assert.doesNotMatch("foo/bar.txt", globToRegex("**/*.env")); + }); +}); + +describe("isHostAllowed", () => { + it("matches exact host", () => { + assert.equal(isHostAllowed("api.openai.com", ["api.openai.com"]), true); + }); + it("matches wildcard subdomain", () => { + assert.equal(isHostAllowed("api.openai.com", ["*.openai.com"]), true); + assert.equal(isHostAllowed("openai.com", ["*.openai.com"]), false); + }); + it("rejects non-matching host", () => { + assert.equal(isHostAllowed("evil.example", ["api.openai.com"]), false); + }); +}); + +describe("assertShellAllowed", () => { + it("blocks rm -rf /", () => { + const policy = loadPolicy({ workspace: process.cwd(), override: { shell: { enabled: true } } }); + assert.throws(() => assertShellAllowed("rm -rf /", policy), SandboxError); + }); + it("blocks fork bombs", () => { + const policy = loadPolicy({ workspace: process.cwd(), override: { shell: { enabled: true } } }); + assert.throws(() => assertShellAllowed(":(){ :|: & };:", policy), SandboxError); + }); + it("blocks when shell is disabled", () => { + const policy = loadPolicy({ workspace: process.cwd(), override: { shell: { enabled: false } } }); + assert.throws(() => assertShellAllowed("ls", policy), SandboxError); + }); + it("allows benign commands", () => { + const policy = loadPolicy({ workspace: process.cwd(), override: { shell: { enabled: true } } }); + assert.doesNotThrow(() => assertShellAllowed("ls -la", policy)); + }); +}); + + diff --git a/test/searchCommand.test.ts b/test/searchCommand.test.ts new file mode 100644 index 0000000..33b1f33 --- /dev/null +++ b/test/searchCommand.test.ts @@ -0,0 +1,48 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { COMMANDS, findCommand } from "../src/commands/index.js"; + +describe("/search and /palette registered", () => { + it("/search command is reachable by name and alias", () => { + const byName = findCommand("search"); + assert.ok(byName); + assert.equal(byName?.name, "search"); + const byAlias = findCommand("find"); + assert.ok(byAlias); + assert.equal(byAlias?.name, "search"); + }); + + it("/palette command is reachable by name and alias", () => { + const byName = findCommand("palette"); + assert.ok(byName); + assert.equal(byName?.name, "palette"); + const byAlias = findCommand("commands"); + assert.ok(byAlias); + assert.equal(byAlias?.name, "palette"); + }); + + it("/search returns usage hint when called without args", async () => { + const cmd = findCommand("search")!; + const r = await cmd.run({ + args: [], + raw: "/search", + // Minimal stub state — search doesn't touch state/actions. + state: {} as never, + actions: {} as never, + }); + assert.match(r.message ?? "", /Usage: \/search/); + }); + + it("/palette dumps every visible command", async () => { + const cmd = findCommand("palette")!; + const r = await cmd.run({ + args: [], + raw: "/palette", + state: {} as never, + actions: {} as never, + }); + const visibleCount = COMMANDS.filter((c) => !c.hidden).length; + const hits = (r.message ?? "").match(/\n {2}• `\//g); + assert.ok(hits && hits.length >= visibleCount - 2); + }); +}); diff --git a/test/stateMachine.test.ts b/test/stateMachine.test.ts new file mode 100644 index 0000000..f0ef7ad --- /dev/null +++ b/test/stateMachine.test.ts @@ -0,0 +1,56 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { AgentStateMachine } from "../src/core/AgentStateMachine.js"; + +describe("AgentStateMachine", () => { + it("starts idle", () => { + const fsm = new AgentStateMachine(); + assert.equal(fsm.get().state, "idle"); + }); + + it("transitions through a normal turn", () => { + const fsm = new AgentStateMachine(); + fsm.send({ type: "user_submit" }); + assert.equal(fsm.get().state, "thinking"); + fsm.send({ type: "tool_start", detail: "shell: npm test" }); + assert.equal(fsm.get().state, "executing"); + assert.equal(fsm.get().detail, "shell: npm test"); + fsm.send({ type: "tool_done" }); + assert.equal(fsm.get().state, "thinking"); + fsm.send({ type: "reset" }); + assert.equal(fsm.get().state, "idle"); + }); + + it("captures error state with code", () => { + const fsm = new AgentStateMachine(); + fsm.send({ type: "user_submit" }); + fsm.send({ type: "tool_failed", code: "SHELL_DENIED", message: "blocked" }); + assert.equal(fsm.get().state, "error"); + assert.equal(fsm.get().errorCode, "SHELL_DENIED"); + assert.equal(fsm.get().label, "blocked"); + }); + + it("waits for confirmation and resumes on approve", () => { + const fsm = new AgentStateMachine(); + fsm.send({ type: "user_submit" }); + fsm.send({ + type: "confirm_request", + payload: { tool: "shell", risk: "high", preview: "rm -rf node_modules" }, + }); + assert.equal(fsm.get().state, "waiting"); + assert.equal(fsm.get().needsConfirm?.risk, "high"); + fsm.send({ type: "confirm_approve" }); + assert.equal(fsm.get().state, "executing"); + assert.equal(fsm.get().needsConfirm, undefined); + }); + + it("notifies subscribers on every state change", () => { + const fsm = new AgentStateMachine(); + const states: string[] = []; + fsm.subscribe((snap) => states.push(snap.state)); + fsm.send({ type: "user_submit" }); + fsm.send({ type: "tool_start", detail: "x" }); + fsm.send({ type: "tool_done" }); + assert.deepEqual(states, ["thinking", "executing", "thinking"]); + }); +}); diff --git a/test/terminalCaps.test.ts b/test/terminalCaps.test.ts new file mode 100644 index 0000000..eb5bc61 --- /dev/null +++ b/test/terminalCaps.test.ts @@ -0,0 +1,37 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { detectCapabilities, redetectSize } from "../src/platform/TerminalCapabilities.js"; + +describe("detectCapabilities", () => { + it("forces no-color when NO_COLOR is set", () => { + const caps = detectCapabilities({ NO_COLOR: "1", LANG: "en_US.UTF-8" }); + assert.equal(caps.colorDepth, 0); + assert.equal(caps.forceNoColor, true); + }); + + it("flags CI environments and turns off animation", () => { + const caps = detectCapabilities({ CI: "true", LANG: "C.UTF-8" }); + assert.equal(caps.isCI, true); + assert.equal(caps.forceNoAnim, true); + }); + + it("classifies tiny vs wide terminals", () => { + const caps = detectCapabilities({ LANG: "en_US.UTF-8" }); + const wide = redetectSize(caps, 200, 60); + assert.equal(wide.sizeClass, "wide"); + const tiny = redetectSize(caps, 50, 20); + assert.equal(tiny.sizeClass, "tiny"); + const compact = redetectSize(caps, 80, 24); + assert.equal(compact.sizeClass, "compact"); + }); + + it("detects truecolor via COLORTERM", () => { + const caps = detectCapabilities({ COLORTERM: "truecolor", LANG: "en_US.UTF-8" }); + assert.equal(caps.colorDepth, 24); + }); + + it("flags SSH when SSH_CLIENT is present", () => { + const caps = detectCapabilities({ SSH_CLIENT: "1.2.3.4 1234 22", LANG: "en_US.UTF-8" }); + assert.equal(caps.isSSH, true); + }); +}); diff --git a/test/themes.test.ts b/test/themes.test.ts new file mode 100644 index 0000000..be6fa7a --- /dev/null +++ b/test/themes.test.ts @@ -0,0 +1,47 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { THEMES, getTheme, pickThemeForCaps, DEFAULT_THEME } from "../src/config/themes.js"; + +describe("themes", () => { + it("includes v0.2 variants", () => { + for (const id of ["fastcode", "neon", "sunset", "matrix", "mono", "cyber", "nocolor", "hicontrast", "termux"]) { + assert.ok(THEMES[id], `theme ${id} missing`); + assert.equal(THEMES[id]!.id, id); + } + }); + + it("nocolor flags noColor=true and uses single-color palette", () => { + const t = THEMES.nocolor!; + assert.equal(t.noColor, true); + // every key resolves to "white" in the no-color theme + assert.equal(t.user, "white"); + assert.equal(t.error, "white"); + assert.equal(t.success, "white"); + }); + + it("getTheme falls back to default for unknown id", () => { + assert.equal(getTheme("does-not-exist").id, DEFAULT_THEME); + assert.equal(getTheme(undefined).id, DEFAULT_THEME); + }); + + it("pickThemeForCaps picks nocolor when forceNoColor is true", () => { + assert.equal( + pickThemeForCaps({ forceNoColor: true, os: "linux", colorDepth: 0 }), + "nocolor", + ); + }); + + it("pickThemeForCaps picks termux on Termux", () => { + assert.equal( + pickThemeForCaps({ forceNoColor: false, os: "termux", colorDepth: 8 }), + "termux", + ); + }); + + it("pickThemeForCaps falls back to default on a normal Linux terminal", () => { + assert.equal( + pickThemeForCaps({ forceNoColor: false, os: "linux", colorDepth: 24 }), + DEFAULT_THEME, + ); + }); +}); diff --git a/test/transcriptVirtual.test.ts b/test/transcriptVirtual.test.ts new file mode 100644 index 0000000..c2edb5b --- /dev/null +++ b/test/transcriptVirtual.test.ts @@ -0,0 +1,37 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { virtualize, clipLines } from "../src/lib/transcriptVirtual.js"; + +describe("virtualize", () => { + it("returns the full list when below the cap", () => { + const items = [1, 2, 3]; + const v = virtualize(items, { maxRender: 10, keepRecent: 5 }); + assert.deepEqual(v.visible, items); + assert.equal(v.dropped, 0); + }); + + it("keeps only the most recent N when above the cap", () => { + const items = Array.from({ length: 100 }, (_, i) => i); + const v = virtualize(items, { maxRender: 10, keepRecent: 5 }); + assert.equal(v.dropped, 95); + assert.equal(v.visible.length, 5); + assert.equal(v.visible[0], 95); + assert.equal(v.visible[4], 99); + }); +}); + +describe("clipLines", () => { + it("returns text unchanged when shorter than the cap", () => { + assert.equal(clipLines("a\nb\nc", 10), "a\nb\nc"); + }); + + it("clips with default marker when above cap", () => { + const out = clipLines("a\nb\nc\nd\ne", 2); + assert.match(out, /^a\nb\n… 3 more lines/); + }); + + it("respects custom marker", () => { + const out = clipLines("1\n2\n3", 1, (n) => `(+${n})`); + assert.equal(out, "1\n(+2)"); + }); +});