diff --git a/components/chat-input-form/coding-mode-button.tsx b/components/chat-input-form/coding-mode-button.tsx new file mode 100644 index 00000000..ddf04507 --- /dev/null +++ b/components/chat-input-form/coding-mode-button.tsx @@ -0,0 +1,421 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Code2, Folder, X } from 'lucide-react'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { useMentorSettings } from '@/hooks/use-mentors/use-mentor-settings'; +import { isTauriOfflineMode } from '@/hooks/use-tauri-offline'; +import { config } from '@/lib/config'; + +const ENABLED_KEY = 'ibl_coding_mode_enabled'; +const MODEL_KEY = 'ibl_coding_mode_model'; +const FOLDER_CHOSEN_KEY = 'ibl_coding_mode_folder_chosen'; +/** The on-device model the user picked in Local Models settings (see ollama-client). */ +const LOCAL_LLM_MODEL_KEY = 'ibl_local_llm_model'; +/** + * Set by the Local Models toggle. This — NOT offline mode — is what the chat transport + * (`use-chat-v2`) uses to route to an on-device model, so Code must read the same flag + * or it will happily point at a cloud model while the rest of the app is local. + */ +const LOCAL_LLM_ENABLED_KEY = 'ibl_local_llm_enabled'; + +/** On-device when Local Models is on, or when the app is forced offline. */ +function readLocalMode(): boolean { + if (typeof window === 'undefined') return false; + return ( + localStorage.getItem(LOCAL_LLM_ENABLED_KEY) === 'true' || isTauriOfflineMode() + ); +} + +interface LocalModelCheck { + runtime: 'ollama' | 'foundry' | 'cloud'; + /** Prefixed spec to persist as the Code model, e.g. "ollama/qwen3:latest". */ + spec: string; + model: string; + running: boolean; + /** null = unknown (Foundry publishes no capability metadata). */ + tools_supported: boolean | null; + reason: string; +} + +async function callTauri( + cmd: string, + args?: Record, +): Promise { + const { invoke } = await import('@tauri-apps/api/core'); + return invoke(cmd, args); +} + +/** + * Resolve the cloud model Code should use — EXACTLY the top-left mentor LLM + * (`/`), with a `matched` flag from validating it against the + * tenant's compat `/v1/models`. There is NO substitution: an unprovisioned model is + * returned as-is (Code turns will fail on it), so a broken selection fails loudly + * rather than silently running a different model. + */ +async function resolveCodingModel( + provider: string, + name: string, +): Promise<{ model: string; matched: boolean }> { + const model = `${provider}/${name}`; + try { + const tenant = localStorage.getItem('tenant') || ''; + const token = localStorage.getItem('dm_token') || ''; + if (!tenant || !token) return { model, matched: false }; + const res = await fetch( + `${config.dmUrl()}/api/ai-mentor/orgs/${tenant}/v1/models`, + { headers: { Authorization: `Token ${token}` } }, + ); + if (!res.ok) return { model, matched: false }; + const json = await res.json(); + const ids: string[] = Array.isArray(json?.data) + ? json.data.map((m: { id?: string }) => m?.id).filter(Boolean) + : []; + return { model, matched: ids.includes(model) }; + } catch { + return { model, matched: false }; + } +} + +/** + * Code (agentic coding via opencode/ACP) control — a desktop-only popover with the + * on/off toggle + workspace folder selector. The SDK chat transport reads + * `ibl_coding_mode_enabled` / `ibl_coding_mode_model` from localStorage; the + * workspace is persisted by the Rust `set_opencode_workspace` command. + */ +export function CodingModeButton() { + const [isOpen, setIsOpen] = useState(false); + const [enabled, setEnabled] = useState( + () => + typeof window !== 'undefined' && + localStorage.getItem(ENABLED_KEY) === 'true', + ); + const [workspace, setWorkspace] = useState(''); + const [resolvedModel, setResolvedModel] = useState(''); + const [modelMatched, setModelMatched] = useState(false); + // null = unknown (status not fetched yet); true = sandboxed MAS build (Code hidden). + const [sandboxed, setSandboxed] = useState(null); + + const { data: mentorSettings } = useMentorSettings(); + const llmProvider = mentorSettings?.llmProvider; + const llmName = mentorSettings?.llmName; + + // An on-device model is active. Code supports these via Ollama / Foundry Local, but + // only when the model can actually call tools. Kept in state and re-read on storage + // changes — the user can flip Local Models while the app is open. + const [isLocal, setIsLocal] = useState(readLocalMode); + const [local, setLocal] = useState(null); + const [localModelId, setLocalModelId] = useState(''); + + useEffect(() => { + const sync = () => { + setIsLocal(readLocalMode()); + setLocalModelId(localStorage.getItem(LOCAL_LLM_MODEL_KEY) || ''); + }; + sync(); + window.addEventListener('storage', sync); + // The app's useLocalStorage fans out same-tab writes on this custom event. + window.addEventListener('local-storage', sync); + return () => { + window.removeEventListener('storage', sync); + window.removeEventListener('local-storage', sync); + }; + }, [isOpen]); + + // opencode is agentic: without tool calling it can't edit files or run commands, so + // it would look broken. Block on a definitive "no" (Ollama reports capabilities); + // allow-with-warning when unknown (Foundry reports nothing). + // + // `unresolved` (the check hasn't answered yet) disables the switch but must NOT be + // treated as a negative — clearing the persisted flag on a pending check would turn + // Code off on every load in local mode. + const localVerdictBad = + isLocal && !!local && (!local.running || local.tools_supported === false); + const blocked = isLocal ? !local || localVerdictBad : false; + + const refresh = async () => { + try { + const ws = await callTauri('get_opencode_workspace'); + setWorkspace(ws || ''); + } catch { + /* best-effort */ + } + }; + + useEffect(() => { + if (isOpen) void refresh(); + }, [isOpen]); + + // Force Code off whenever it can't run (runtime down, or an on-device model with no + // tool calling) — the send path (SDK) reads this flag, so clearing it routes back to + // normal chat instead of failing every turn. + useEffect(() => { + if (localVerdictBad && localStorage.getItem(ENABLED_KEY) === 'true') { + localStorage.setItem(ENABLED_KEY, 'false'); + setEnabled(false); + } + }, [localVerdictBad]); + + // On-device: ask the backend which runtime serves the selected local model and + // whether it can drive Code. Rust auto-detects Ollama vs Foundry Local. + useEffect(() => { + if (!isLocal || sandboxed !== false) return; + let cancelled = false; + void (async () => { + try { + const res = await callTauri('check_code_local_model', { + model: localModelId || localStorage.getItem(LOCAL_LLM_MODEL_KEY) || '', + }); + if (cancelled || !res) return; + setLocal(res); + // The prefixed spec is what routes the spawn to the local runtime. + if (res.running && res.tools_supported !== false && res.spec) { + localStorage.setItem(MODEL_KEY, res.spec); + setResolvedModel(res.spec); + setModelMatched(true); + } + } catch { + /* best-effort */ + } + })(); + return () => { + cancelled = true; + }; + }, [isLocal, sandboxed, enabled, isOpen, localModelId]); + + // Cloud: keep Code's model matched to the top-left mentor LLM (validated) whenever + // it changes, while Code is on or the popover is open. + useEffect(() => { + if (isLocal || (!enabled && !isOpen) || !llmProvider || !llmName) return; + let cancelled = false; + void (async () => { + const { model, matched } = await resolveCodingModel(llmProvider, llmName); + if (cancelled) return; + localStorage.setItem(MODEL_KEY, model); + setResolvedModel(model); + setModelMatched(matched); + })(); + return () => { + cancelled = true; + }; + }, [isLocal, enabled, isOpen, llmProvider, llmName]); + + // Detect the sandboxed (Mac App Store) build once — Code can't spawn the opencode + // binary there, so it's hidden entirely. + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const st = await callTauri<{ sandboxed?: boolean }>( + 'check_opencode_status', + ); + if (!cancelled) setSandboxed(!!st?.sandboxed); + } catch { + if (!cancelled) setSandboxed(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + // Default Code ON for logged-in desktop users (once). Respects an explicit prior + // choice, skips the sandboxed build + on-device-model (blocked) case, and does NOT + // prompt for a folder — the default workspace is used until the user changes it. + useEffect(() => { + if (sandboxed !== false || blocked) return; + if (localStorage.getItem(ENABLED_KEY) !== null) return; + const loggedIn = + !!localStorage.getItem('tenant') && !!localStorage.getItem('dm_token'); + if (!loggedIn) return; + localStorage.setItem(ENABLED_KEY, 'true'); + setEnabled(true); + if (isLocal && local?.spec) { + localStorage.setItem(MODEL_KEY, local.spec); + } else if (!isLocal && llmProvider && llmName) { + localStorage.setItem(MODEL_KEY, `${llmProvider}/${llmName}`); + } + // Prep opencode in the background so the first turn is ready (best-effort). + callTauri('install_opencode').catch(() => {}); + }, [sandboxed, blocked, isLocal, local?.spec, llmProvider, llmName]); + + // Native folder picker → persist via set_opencode_workspace (which mkdir -p's + + // git init's the folder). + const pickFolder = async () => { + try { + const { open } = await import('@tauri-apps/plugin-dialog'); + const selected = await open({ + directory: true, + multiple: false, + defaultPath: workspace || undefined, + title: 'Choose your workspace folder', + }); + const path = typeof selected === 'string' ? selected : null; + if (path) { + const saved = await callTauri('set_opencode_workspace', { + path, + }); + setWorkspace(saved || path); + localStorage.setItem(FOLDER_CHOSEN_KEY, 'true'); + } + } catch (e) { + console.error('[coding-mode] folder pick failed', e); + } + }; + + const toggle = async (next: boolean) => { + if (blocked) return; // can't enable Code while a local model is active + setEnabled(next); + localStorage.setItem(ENABLED_KEY, next ? 'true' : 'false'); + if (!next) return; + // Seed the model with the EXACT current selection (no default) so the send path + // never substitutes; the resolve effects then flag whether it's actually usable. + if (isLocal && local?.spec) { + localStorage.setItem(MODEL_KEY, local.spec); + } else if (!isLocal && llmProvider && llmName) { + localStorage.setItem(MODEL_KEY, `${llmProvider}/${llmName}`); + } + // First enable → force a deliberate folder choice immediately. + if (!localStorage.getItem(FOLDER_CHOSEN_KEY)) { + await pickFolder(); + } + // Prep the coding agent (download + config + git-init the workspace), best-effort. + try { + await callTauri('install_opencode'); + } catch (e) { + console.error('[coding-mode] install failed', e); + } + void refresh(); + }; + + const active = enabled || isOpen; + + // Hidden in the sandboxed Mac App Store build, where opencode can't be spawned at + // all. (Desktop-only gating happens in the parent, which won't mount this outside + // Tauri.) + if (sandboxed) return null; + + return ( + + + + + + + + {!isOpen && ( + + An agentic coding tool for your project folder + + )} + + +
+
+ + Code +
+ +
+

+ An agentic coding tool that edits files, runs commands, and commits + changes in the folder you choose. +

+ + {isLocal && blocked ? ( +
+ {local?.reason || + 'Checking whether your on-device model can run Code…'} + {local?.tools_supported === false && ( + <> Try a tool-capable model such as qwen3, llama3.2 or phi4-mini. + )} +
+ ) : isLocal ? ( +
+ Model:{' '} + {local?.model}{' '} + (on-device) +
+ First run can take a few minutes while the model loads. +
+ {local?.tools_supported === null && local?.reason && ( +
{local.reason}
+ )} +
+ ) : resolvedModel && !modelMatched ? ( +
+ {resolvedModel} isn’t available + for Code — turns will fail. Pick a different model in the top-left. +
+ ) : ( +
+ Model:{' '} + + {resolvedModel || 'matching your selected LLM…'} + +
+ )} + +
+
+ + Workspace +
+
+ {workspace || '—'} +
+ +
+
+
+ ); +} diff --git a/components/chat-input-form/inside-buttons.tsx b/components/chat-input-form/inside-buttons.tsx index facb550c..d9f0e58e 100644 --- a/components/chat-input-form/inside-buttons.tsx +++ b/components/chat-input-form/inside-buttons.tsx @@ -1,7 +1,7 @@ 'use client'; import type React from 'react'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/button'; import { @@ -19,7 +19,9 @@ import { X, BookOpen, Archive, Check, Terminal } from 'lucide-react'; import { DeepSearchIcon, CanvasIcon } from '@/components/icons/svg-icons'; import { TOOLS } from '@iblai/iblai-js/web-utils'; import { MemoryButton } from './memory-button'; +import { CodingModeButton } from './coding-mode-button'; import { MemoryMenu } from './memory-menu'; +import { isTauriApp } from '@/types/tauri'; interface InsideButtonsProps { activeOptions: string[]; @@ -61,6 +63,27 @@ export const InsideButtons = ({ username, }: InsideButtonsProps) => { const t = useTranslations('chatInputFormInsideButtons'); + + // Code (opencode over ACP) is desktop-only. Detected AFTER mount, never during + // render: Tauri injects its globals into the remote origin some time after load, so a + // render-time read can latch false forever (and would mismatch prerendered HTML + // during hydration). Keeping the gate here also means — which + // needs Redux + the mentor route — never mounts in a plain browser. + const [inTauri, setInTauri] = useState(false); + useEffect(() => { + if (isTauriApp()) return setInTauri(true); + let tries = 0; + const t = setInterval(() => { + if (isTauriApp()) { + setInTauri(true); + clearInterval(t); + } else if (++tries > 10) { + clearInterval(t); + } + }, 500); + return () => clearInterval(t); + }, []); + const allInsideButtons = [ { name: 'Canvas', @@ -136,6 +159,8 @@ export const InsideButtons = ({ return (
+ {/* Coding Mode (desktop-only) — always inline; owns its own folder popover. */} + {inTauri && } {/* Responsive Inside Buttons */} {visibleInsideButtons.map((button) => { if (button.name === 'Memory') { diff --git a/hooks/use-mentors/use-mentor-settings.ts b/hooks/use-mentors/use-mentor-settings.ts index 3a4bc46f..dddee39b 100644 --- a/hooks/use-mentors/use-mentor-settings.ts +++ b/hooks/use-mentors/use-mentor-settings.ts @@ -29,15 +29,21 @@ export function useMentorSettings({ mentorId: mentorIdFromProps, tenantKey: tenantKeyFromProps, }: Props = {}) { - const { tenantKey: tenantKeyFromParams, mentorId: mentorIdFromParams } = - useParams(); + // `useParams()` is null outside a matched route segment, so destructuring it + // directly throws ("Cannot destructure property 'tenantKey' of null") and takes down + // whatever rendered the caller. Read it defensively — callers already handle a + // missing mentorId/tenantKey (the query is skipped below). + const params = useParams(); + const tenantKeyFromParams = params?.tenantKey; + const mentorIdFromParams = params?.mentorId; const username = useUsername(); const COMMUNITY_MENTOR_VISIBILITY = MentorVisibilityEnum.VIEWABLE_BY_ANYONE; const mentorId = mentorIdFromProps || mentorIdFromParams; const tenantKey = tenantKeyFromProps || tenantKeyFromParams; const isLoggedIn = Boolean(username); const searchParams = useSearchParams(); - const isAccessingPublicRoute = !!searchParams.get('token'); + // Null outside a matched route segment, same as `useParams()` above. + const isAccessingPublicRoute = !!searchParams?.get('token'); // Check if we're in Tauri offline mode const isOffline = isTauriApp() && isTauriOfflineMode(); diff --git a/package.json b/package.json index dad4ec5e..b55d5283 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "@sentry/nextjs": "10.62.0", "@tanstack/react-form": "1.6.3", "@tauri-apps/api": "2.11.0", + "@tauri-apps/plugin-dialog": "2.7.2", "@tauri-apps/plugin-opener": "2.5.3", "@tauri-apps/plugin-os": "2.3.2", "@tauri-apps/plugin-shell": "2.3.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f497c4ca..f67594ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,13 +141,16 @@ importers: version: 2.7.0(react-redux@9.2.0(@types/react@19.1.17)(react@19.1.0)(redux@5.0.1))(react@19.1.0) '@sentry/nextjs': specifier: 10.62.0 - version: 10.62.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))(next@15.5.21(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.0)(@types/node@20.17.48)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.108.4(postcss@8.5.23)) + version: 10.62.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))(next@15.5.21(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.0)(@types/node@20.17.48)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.108.4(postcss@8.5.24)) '@tanstack/react-form': specifier: 1.6.3 version: 1.6.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@tauri-apps/api': specifier: 2.11.0 version: 2.11.0 + '@tauri-apps/plugin-dialog': + specifier: 2.7.2 + version: 2.7.2 '@tauri-apps/plugin-opener': specifier: 2.5.3 version: 2.5.3 @@ -433,7 +436,7 @@ importers: version: 3.2.6(vitest@3.2.6) autoprefixer: specifier: 10.4.27 - version: 10.4.27(postcss@8.5.23) + version: 10.4.27(postcss@8.5.24) cross-env: specifier: 7.0.3 version: 7.0.3 @@ -463,10 +466,10 @@ importers: version: 16.4.0 postcss: specifier: '>=8.5.18' - version: 8.5.23 + version: 8.5.24 postcss-loader: specifier: 8.2.1 - version: 8.2.1(postcss@8.5.23)(typescript@5.9.3)(webpack@5.108.4(postcss@8.5.23)) + version: 8.2.1(postcss@8.5.24)(typescript@5.9.3)(webpack@5.108.4(postcss@8.5.24)) prettier: specifier: 3.5.3 version: 3.5.3 @@ -3794,6 +3797,9 @@ packages: '@tauri-apps/api@2.11.0': resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==} + '@tauri-apps/plugin-dialog@2.7.2': + resolution: {integrity: sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==} + '@tauri-apps/plugin-opener@2.5.3': resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==} @@ -7851,8 +7857,8 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.5.23: - resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} + postcss@8.5.24: + resolution: {integrity: sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==} engines: {node: ^10 || ^12 || >=14} prebuild-install@7.1.3: @@ -12427,7 +12433,7 @@ snapshots: dependencies: '@sentry/core': 10.62.0 - '@sentry/nextjs@10.62.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))(next@15.5.21(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.0)(@types/node@20.17.48)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.108.4(postcss@8.5.23))': + '@sentry/nextjs@10.62.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))(next@15.5.21(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.0)(@types/node@20.17.48)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.108.4(postcss@8.5.24))': dependencies: '@opentelemetry/api': 1.9.1 '@rollup/plugin-commonjs': 28.0.1(rollup@4.62.2) @@ -12439,7 +12445,7 @@ snapshots: '@sentry/opentelemetry': 10.62.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1)) '@sentry/react': 10.62.0(react@19.1.0) '@sentry/vercel-edge': 10.62.0 - '@sentry/webpack-plugin': 5.3.0(webpack@5.108.4(postcss@8.5.23)) + '@sentry/webpack-plugin': 5.3.0(webpack@5.108.4(postcss@8.5.24)) next: 15.5.21(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.0)(@types/node@20.17.48)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) rollup: 4.62.2 stacktrace-parser: 0.1.11 @@ -12520,10 +12526,10 @@ snapshots: '@opentelemetry/api': 1.9.1 '@sentry/core': 10.62.0 - '@sentry/webpack-plugin@5.3.0(webpack@5.108.4(postcss@8.5.23))': + '@sentry/webpack-plugin@5.3.0(webpack@5.108.4(postcss@8.5.24))': dependencies: '@sentry/bundler-plugin-core': 5.3.0 - webpack: 5.108.4(postcss@8.5.23) + webpack: 5.108.4(postcss@8.5.24) transitivePeerDependencies: - encoding - supports-color @@ -12683,7 +12689,7 @@ snapshots: '@alloc/quick-lru': 5.2.0 '@tailwindcss/node': 4.1.4 '@tailwindcss/oxide': 4.1.4 - postcss: 8.5.23 + postcss: 8.5.24 tailwindcss: 4.1.4 '@tanstack/form-core@1.11.2': @@ -12725,6 +12731,10 @@ snapshots: '@tauri-apps/api@2.11.0': {} + '@tauri-apps/plugin-dialog@2.7.2': + dependencies: + '@tauri-apps/api': 2.11.0 + '@tauri-apps/plugin-opener@2.5.3': dependencies: '@tauri-apps/api': 2.11.0 @@ -13884,13 +13894,13 @@ snapshots: attr-accept@2.2.5: {} - autoprefixer@10.4.27(postcss@8.5.23): + autoprefixer@10.4.27(postcss@8.5.24): dependencies: browserslist: 4.28.5 caniuse-lite: 1.0.30001802 fraction.js: 5.3.4 picocolors: 1.1.1 - postcss: 8.5.23 + postcss: 8.5.24 postcss-value-parser: 4.2.0 available-typed-arrays@1.0.7: @@ -16882,15 +16892,15 @@ snapshots: minimist@1.2.8: {} - minimizer-webpack-plugin@5.6.1(postcss@8.5.23)(webpack@5.108.4(postcss@8.5.23)): + minimizer-webpack-plugin@5.6.1(postcss@8.5.24)(webpack@5.108.4(postcss@8.5.24)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 terser: 5.48.0 - webpack: 5.108.4(postcss@8.5.23) + webpack: 5.108.4(postcss@8.5.24) optionalDependencies: - postcss: 8.5.23 + postcss: 8.5.24 minipass@7.1.3: {} @@ -16959,7 +16969,7 @@ snapshots: '@next/env': 15.5.21 '@swc/helpers': 0.5.15 caniuse-lite: 1.0.30001802 - postcss: 8.5.23 + postcss: 8.5.24 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.1.0) @@ -17307,20 +17317,20 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-loader@8.2.1(postcss@8.5.23)(typescript@5.9.3)(webpack@5.108.4(postcss@8.5.23)): + postcss-loader@8.2.1(postcss@8.5.24)(typescript@5.9.3)(webpack@5.108.4(postcss@8.5.24)): dependencies: cosmiconfig: 9.0.2(typescript@5.9.3) jiti: 2.7.0 - postcss: 8.5.23 + postcss: 8.5.24 semver: 7.8.5 optionalDependencies: - webpack: 5.108.4(postcss@8.5.23) + webpack: 5.108.4(postcss@8.5.24) transitivePeerDependencies: - typescript postcss-value-parser@4.2.0: {} - postcss@8.5.23: + postcss@8.5.24: dependencies: nanoid: 3.3.16 picocolors: 1.1.1 @@ -19026,7 +19036,7 @@ snapshots: dependencies: lightningcss: 1.32.0 picomatch: 4.0.5 - postcss: 8.5.23 + postcss: 8.5.24 rolldown: 1.1.4 tinyglobby: 0.2.17 optionalDependencies: @@ -19121,7 +19131,7 @@ snapshots: webpack-sources@3.5.1: {} - webpack@5.108.4(postcss@8.5.23): + webpack@5.108.4(postcss@8.5.24): dependencies: '@types/estree': 1.0.9 '@types/json-schema': 7.0.15 @@ -19139,7 +19149,7 @@ snapshots: graceful-fs: 4.2.11 loader-runner: 4.3.2 mime-db: 1.54.0 - minimizer-webpack-plugin: 5.6.1(postcss@8.5.23)(webpack@5.108.4(postcss@8.5.23)) + minimizer-webpack-plugin: 5.6.1(postcss@8.5.24)(webpack@5.108.4(postcss@8.5.24)) neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8669ea3b..8cffda1d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -45,3 +45,10 @@ overrides: # (see patches/google-drive-picker@1.1.29.patch and its regression test). patchedDependencies: google-drive-picker@1.1.29: patches/google-drive-picker@1.1.29.patch + +# Local Verdaccio publishes of @iblai/* carry throwaway -acp suffixes newer than the +# min-release-age floor (.npmrc: min-release-age=7). Exclude them so +# `pnpm i --registry http://localhost:4873/` can install the freshly published Coding +# Mode SDK. Local scaffolding — remove before PR. +minimumReleaseAgeExclude: + - '@iblai/*' diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 868dfc46..6fb84088 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2053,6 +2053,7 @@ dependencies = [ "tauri-build", "tauri-plugin-deep-link", "tauri-plugin-devtools", + "tauri-plugin-dialog", "tauri-plugin-macos-permissions", "tauri-plugin-opener", "tauri-plugin-os", @@ -3687,6 +3688,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -4607,6 +4632,48 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-macos-permissions" version = "2.3.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3d4a96ca..b276f1a0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,6 +35,7 @@ tower-http = { version = "0.6", features = ["cors", "fs"] } mime_guess = "2.0" base64 = "0.22" tauri-plugin-os = "2.3.2" +tauri-plugin-dialog = "2" [target.'cfg(target_os = "ios")'.dependencies] objc = "0.2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index e2614bbf..ca4d02cc 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -32,6 +32,14 @@ "allow-install-ghost-os", "allow-stop-ghost-os", "allow-check-ghost-os-status", + "allow-opencode-chat-stream", + "allow-opencode-stop", + "allow-opencode-close", + "allow-get-opencode-workspace", + "allow-set-opencode-workspace", + "allow-install-opencode", + "allow-check-opencode-status", + "allow-check-code-local-model", "core:webview:allow-internal-toggle-devtools", "core:default", "core:event:default", @@ -52,6 +60,7 @@ "core:webview:allow-create-webview-window", "opener:default", "shell:allow-open", + "dialog:allow-open", "allow-open-external-url", "allow-navigate-to", "allow-get-os-type", diff --git a/src-tauri/permissions/default.toml b/src-tauri/permissions/default.toml index 9b0e185c..6f301660 100644 --- a/src-tauri/permissions/default.toml +++ b/src-tauri/permissions/default.toml @@ -24,6 +24,14 @@ permissions = [ "allow-install-ghost-os", "allow-stop-ghost-os", "allow-check-ghost-os-status", + "allow-opencode-chat-stream", + "allow-opencode-stop", + "allow-opencode-close", + "allow-get-opencode-workspace", + "allow-set-opencode-workspace", + "allow-install-opencode", + "allow-check-opencode-status", + "allow-check-code-local-model", ] [[permission]] @@ -151,3 +159,51 @@ identifier = "allow-check-ghost-os-status" description = "Enables checking GhostOS (System Control) status" [permission.commands] allow = ["check_ghost_os_status"] + +[[permission]] +identifier = "allow-opencode-chat-stream" +description = "Enables streaming a Coding Mode turn through opencode over ACP" +[permission.commands] +allow = ["opencode_chat_stream"] + +[[permission]] +identifier = "allow-opencode-stop" +description = "Enables cancelling the active opencode turn (ACP session/cancel)" +[permission.commands] +allow = ["opencode_stop"] + +[[permission]] +identifier = "allow-opencode-close" +description = "Enables killing a chat's opencode process" +[permission.commands] +allow = ["opencode_close"] + +[[permission]] +identifier = "allow-get-opencode-workspace" +description = "Enables reading the Coding Mode workspace path" +[permission.commands] +allow = ["get_opencode_workspace"] + +[[permission]] +identifier = "allow-set-opencode-workspace" +description = "Enables setting the Coding Mode workspace path" +[permission.commands] +allow = ["set_opencode_workspace"] + +[[permission]] +identifier = "allow-install-opencode" +description = "Enables installing opencode + writing its config + preparing the workspace" +[permission.commands] +allow = ["install_opencode"] + +[[permission]] +identifier = "allow-check-opencode-status" +description = "Enables checking opencode readiness (installed/version/config/workspace)" +[permission.commands] +allow = ["check_opencode_status"] + +[[permission]] +identifier = "allow-check-code-local-model" +description = "Enables checking whether the selected on-device model can drive Code (tool-calling support, runtime reachability)" +[permission.commands] +allow = ["check_code_local_model"] diff --git a/src-tauri/src/foundry_manager.rs b/src-tauri/src/foundry_manager.rs index 64646b53..7a61291e 100644 --- a/src-tauri/src/foundry_manager.rs +++ b/src-tauri/src/foundry_manager.rs @@ -364,7 +364,9 @@ fn parse_plain_text_models(stdout: &str, downloaded_models: &std::collections::H } /// Get the actual Foundry service endpoint by running `foundry service status` -fn get_foundry_service_endpoint() -> Option { +/// (the port is dynamic). `pub` so Code (opencode over ACP) can point opencode at +/// Foundry's OpenAI-compatible API at `{endpoint}/v1`. +pub fn get_foundry_service_endpoint() -> Option { let output = create_command("foundry") .args(&["service", "status"]) .output() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c0b3ee83..dee2e2b6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,12 @@ // Hide console window on Windows in release builds #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +// Gated exactly like `opencode_acp`, which is its only consumer here: Code uses +// `get_foundry_service_endpoint` to reach Foundry Local's OpenAI-compatible API. +// The rest of the module is exercised by the desktop bin (see main.rs). +#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] +#[allow(dead_code)] +mod foundry_manager; mod ghost_os_manager; mod mcp_bridge_installer; mod mcp_bridge_manager; @@ -10,6 +16,10 @@ mod offline_server; mod ollama_installer; #[cfg(not(any(target_os = "ios", target_os = "android")))] mod web_cache; +#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] +mod opencode_acp; +#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] +mod opencode_installer; use model_manager::{ cancel_download, check_disk_space, check_ollama_installed, get_timestamp, is_model_installed, @@ -2057,6 +2067,7 @@ pub fn run() { .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_os::init()) + .plugin(tauri_plugin_dialog::init()) .setup(|app| { let app_url = get_app_url(); println!("[ibl.ai] ============================================"); @@ -2728,6 +2739,14 @@ pub fn run() { navigate_to, ollama_chat, ollama_chat_stream, + opencode_acp::opencode_chat_stream, + opencode_acp::opencode_stop, + opencode_acp::opencode_close, + opencode_acp::get_opencode_workspace, + opencode_acp::set_opencode_workspace, + opencode_installer::install_opencode, + opencode_installer::check_opencode_status, + opencode_acp::check_code_local_model, ]); // Mobile platforms get only basic commands (no offline/cache features) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 607ac034..583861fb 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -11,6 +11,10 @@ mod oauth; mod offline_server; mod ollama_installer; mod web_cache; +#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] +mod opencode_acp; +#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] +mod opencode_installer; use foundry_installer::{ download_and_install_foundry, download_foundry_model, get_recommended_models, @@ -2148,6 +2152,7 @@ fn main() { .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_os::init()) + .plugin(tauri_plugin_dialog::init()) .setup(|app| { // Initialize web cache with app data directory let app_data_dir = app @@ -2756,6 +2761,14 @@ fn main() { oauth::oauth_start, oauth::oauth_callback, oauth::oauth_get_result, + opencode_acp::opencode_chat_stream, + opencode_acp::opencode_stop, + opencode_acp::opencode_close, + opencode_acp::get_opencode_workspace, + opencode_acp::set_opencode_workspace, + opencode_installer::install_opencode, + opencode_installer::check_opencode_status, + opencode_acp::check_code_local_model, ]) .run(tauri::generate_context!()) .expect("error while running tauri app"); diff --git a/src-tauri/src/opencode_acp.rs b/src-tauri/src/opencode_acp.rs new file mode 100644 index 00000000..21569ac8 --- /dev/null +++ b/src-tauri/src/opencode_acp.rs @@ -0,0 +1,950 @@ +//! opencode ACP transport for "Coding Mode". +//! +//! Spawns `opencode acp` as a long-lived subprocess per chat session and speaks +//! the Agent Client Protocol (newline-delimited JSON-RPC over stdio). opencode is +//! configured (via `~/.config/iblai/agents/opencode/opencode.json` + +//! `XDG_CONFIG_HOME`) to use ibl.ai's OpenAI-compatible API as its model provider. +//! +//! Agent `session/update` notifications are translated into the SAME Tauri events +//! the chat UI already renders for local models — `ollama:token` / `ollama:done` / +//! `ollama:error` (keyed by `generation_id`) — plus `opencode:reasoning` and +//! `opencode:tool_call` for thinking and tool activity. +//! +//! This is hand-rolled (serde_json + tokio) rather than using the +//! `agent-client-protocol` crate: the ACP surface we need is small and we want +//! precise control over per-delta emit throttling, cancellation, and process reuse +//! — mirroring the existing `ollama_chat_stream` NDJSON loop. + +#![cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::{Arc, OnceLock}; +use std::time::{Duration, Instant}; + +use serde_json::{json, Value}; +use tauri::{command, AppHandle, Emitter}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStdin, Command}; +use tokio::sync::{oneshot, Mutex}; + +/// Default ibl.ai ASGI streaming host — the OpenAI-compatible chat/completions +/// stream is served HERE, not the `api.iblai.app/dm` gateway (which 500s on chat). +/// Full model endpoint: `{API_BASE}/api/ai-mentor/orgs//v1`. +const DEFAULT_API_BASE: &str = "https://asgi.data.iblai.app"; + +/// Coalesce assistant-token emits to at most one per this window — the buffer that +/// keeps a fast opencode stream from re-render-storming (and freezing) the webview, +/// per the pattern in CLAUDE.local.md. `ollama:done` always carries the final +/// `full_content`, so widening the window never drops the trailing text. +// ponytail: 200ms = 5 renders/sec — coarse enough to stop the freeze, still reads as +// live streaming. Lower for snappier text; add a frontend typewriter buffer if a +// heavy message renderer still janks at this rate. +const TOKEN_EMIT_WINDOW: Duration = Duration::from_millis(200); + +/// Respawn the process with a fresh token when the spawn token is within this many +/// seconds of expiry (proactive refresh — the frontend passes a fresh dm_token on +/// every send). +const TOKEN_REFRESH_SLACK_SECS: i64 = 120; + +type Registry = Mutex>>; + +fn registry() -> &'static Registry { + static REG: OnceLock = OnceLock::new(); + REG.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Per-turn streaming accumulator, shared between the reader task and the command. +struct TurnState { + generation_id: String, + full_content: String, + pending_delta: String, + last_emit: Instant, +} + +impl TurnState { + fn reset(&mut self, generation_id: String) { + self.generation_id = generation_id; + self.full_content.clear(); + self.pending_delta.clear(); + self.last_emit = Instant::now(); + } +} + +/// A live `opencode acp` process + its ACP session. +struct Session { + child: Mutex, + stdin: Arc>, + next_id: AtomicI64, + pending: Arc>>>, + acp_session_id: String, + spawn_token_exp: Option, + /// The compat model id this process was spawned for (e.g. "openai/gpt-5.5"); a + /// change means the mentor's LLM switched → respawn with the new model. + requested_model: Option, + turn: Arc>, +} + +impl Session { + fn new_id(&self) -> i64 { + self.next_id.fetch_add(1, Ordering::SeqCst) + } + + /// Send a JSON-RPC request and await its response result. + async fn request(&self, method: &str, params: Value) -> Result { + let id = self.new_id(); + let (tx, rx) = oneshot::channel(); + self.pending.lock().await.insert(id, tx); + let msg = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }); + write_line(&self.stdin, &msg).await?; + let resp = rx + .await + .map_err(|_| format!("opencode closed before responding to {method}"))?; + if let Some(err) = resp.get("error") { + return Err(format!("opencode error on {method}: {err}")); + } + Ok(resp.get("result").cloned().unwrap_or(Value::Null)) + } + + /// Send a JSON-RPC notification (no response expected). + async fn notify(&self, method: &str, params: Value) -> Result<(), String> { + let msg = json!({ "jsonrpc": "2.0", "method": method, "params": params }); + write_line(&self.stdin, &msg).await + } +} + +/// Write one JSON value as a single ndJSON line to the child's stdin. +async fn write_line(stdin: &Arc>, msg: &Value) -> Result<(), String> { + eprintln!("[acp→] {msg}"); + let mut line = serde_json::to_string(msg).map_err(|e| e.to_string())?; + line.push('\n'); + let mut guard = stdin.lock().await; + guard + .write_all(line.as_bytes()) + .await + .map_err(|e| format!("failed writing to opencode: {e}"))?; + guard.flush().await.map_err(|e| e.to_string()) +} + +/// Build a tokio Command with a hidden console window on Windows. +fn create_command(program: &str) -> Command { + let cmd = Command::new(program); + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + let mut cmd = cmd; + cmd.creation_flags(CREATE_NO_WINDOW); + return cmd; + } + #[allow(unreachable_code)] + cmd +} + +/// Home directory (HOME on unix, USERPROFILE on Windows). +fn home_dir() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) +} + +/// opencode config home so it reads `~/.config/iblai/agents/opencode/opencode.json`. +fn config_home() -> PathBuf { + home_dir() + .unwrap_or_default() + .join(".config") + .join("iblai") + .join("agents") +} + +/// Base data dir for ibl.ai desktop artifacts: `~/.local/share/iblai` +/// (respects `XDG_DATA_HOME`). Holds the managed opencode binary + workspaces. +pub fn iblai_data_dir() -> PathBuf { + if let Some(xdg) = std::env::var_os("XDG_DATA_HOME") { + return PathBuf::from(xdg).join("iblai"); + } + home_dir().unwrap_or_default().join(".local/share/iblai") +} + +/// Managed opencode binary: `~/.local/share/iblai/bin/opencode[.exe]`. +pub fn opencode_bin() -> PathBuf { + let name = if cfg!(target_os = "windows") { + "opencode.exe" + } else { + "opencode" + }; + iblai_data_dir().join("bin").join(name) +} + +/// The opencode program to spawn: the managed binary if present, else PATH. +pub fn opencode_program() -> String { + let bin = opencode_bin(); + if bin.exists() { + bin.to_string_lossy().to_string() + } else { + "opencode".to_string() + } +} + +/// Default coding workspace: `~/.local/share/iblai/workspaces/main`. +/// (Overridable via `set_opencode_workspace`; see workspace management.) +pub fn default_workspace() -> PathBuf { + iblai_data_dir().join("workspaces/main") +} + +/// File persisting the user's chosen workspace path. +fn workspace_state_file() -> PathBuf { + home_dir() + .unwrap_or_default() + .join(".local/share/iblai/workspace.txt") +} + +/// The active workspace: the persisted user choice, or the default. +pub fn resolve_workspace() -> PathBuf { + if let Ok(s) = std::fs::read_to_string(workspace_state_file()) { + let s = s.trim(); + if !s.is_empty() { + return PathBuf::from(s); + } + } + default_workspace() +} + +/// Return the active coding workspace path. +#[command] +pub async fn get_opencode_workspace() -> Result { + Ok(resolve_workspace().to_string_lossy().to_string()) +} + +/// Set (and persist) the coding workspace. The frontend supplies the path from a +/// native folder picker (`@tauri-apps/plugin-dialog`). +#[command] +pub async fn set_opencode_workspace(path: String) -> Result { + let dir = PathBuf::from(&path); + ensure_workspace(&dir)?; + let f = workspace_state_file(); + if let Some(parent) = f.parent() { + let _ = std::fs::create_dir_all(parent); + } + std::fs::write(&f, dir.to_string_lossy().as_bytes()).map_err(|e| e.to_string())?; + Ok(dir.to_string_lossy().to_string()) +} + +/// Ensure the workspace dir exists and is a git repo (safety net for auto-approved edits). +fn ensure_workspace(dir: &PathBuf) -> Result<(), String> { + std::fs::create_dir_all(dir).map_err(|e| format!("workspace create failed: {e}"))?; + if !dir.join(".git").exists() { + let _ = std::process::Command::new("git") + .arg("init") + .current_dir(dir) + .output(); + } + Ok(()) +} + +/// Best-effort git snapshot commit (revert safety for auto-approved edits). +fn git_snapshot(dir: &PathBuf, message: &str) { + let _ = std::process::Command::new("git") + .args(["add", "-A"]) + .current_dir(dir) + .output(); + let _ = std::process::Command::new("git") + .args(["commit", "--no-gpg-sign", "-m", message]) + .current_dir(dir) + .output(); +} + +/// Decode the `exp` (seconds since epoch) claim from a JWT without verifying it. +fn jwt_exp(token: &str) -> Option { + use base64::Engine; + let payload = token.split('.').nth(1)?; + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .ok()?; + let claims: Value = serde_json::from_slice(&bytes).ok()?; + claims.get("exp").and_then(|e| e.as_i64()) +} + +fn now_secs() -> i64 { + chrono::Utc::now().timestamp() +} + +/// Extract the latest user-message text from the frontend `messages` array. +/// opencode keeps its own conversation state, so we only forward the newest turn. +fn last_user_text(messages: &[Value]) -> Option { + for m in messages.iter().rev() { + if m.get("role").and_then(|r| r.as_str()) == Some("user") { + match m.get("content") { + Some(Value::String(s)) => return Some(s.clone()), + Some(Value::Array(parts)) => { + let text: String = parts + .iter() + .filter_map(|p| p.get("text").and_then(|t| t.as_str())) + .collect::>() + .join(""); + if !text.is_empty() { + return Some(text); + } + } + _ => {} + } + } + } + None +} + +/// Reader loop: routes agent responses to `pending`, auto-approves permission +/// requests, and translates `session/update` notifications into Tauri events. +async fn reader_loop( + app: AppHandle, + stdout: tokio::process::ChildStdout, + stdin: Arc>, + pending: Arc>>>, + turn: Arc>, +) { + let mut lines = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if line.trim().is_empty() { + continue; + } + eprintln!("[acp←] {line}"); + let Ok(v) = serde_json::from_str::(&line) else { + continue; + }; + + // Response to one of our requests. + if v.get("id").is_some() && (v.get("result").is_some() || v.get("error").is_some()) { + if let Some(id) = v.get("id").and_then(|i| i.as_i64()) { + if let Some(tx) = pending.lock().await.remove(&id) { + let _ = tx.send(v); + } + } + continue; + } + + let method = v.get("method").and_then(|m| m.as_str()).unwrap_or(""); + + // Request from the agent (has id + method) → we must respond. + if v.get("id").is_some() && !method.is_empty() { + let id = v.get("id").cloned().unwrap_or(Value::Null); + if method == "session/request_permission" { + // Auto-approve: pick an "allow" option (first that starts with allow). + let option_id = v + .get("params") + .and_then(|p| p.get("options")) + .and_then(|o| o.as_array()) + .and_then(|opts| { + opts.iter() + .find(|o| { + o.get("kind") + .and_then(|k| k.as_str()) + .map(|k| k.starts_with("allow")) + .unwrap_or(false) + }) + .or_else(|| opts.first()) + }) + .and_then(|o| o.get("optionId").and_then(|x| x.as_str())) + .map(|s| s.to_string()); + + let result = match option_id { + Some(oid) => json!({ "outcome": { "outcome": "selected", "optionId": oid } }), + None => json!({ "outcome": { "outcome": "cancelled" } }), + }; + let _ = write_line(&stdin, &json!({ "jsonrpc": "2.0", "id": id, "result": result })).await; + } else { + // We advertised no fs/terminal capabilities, so opencode does its own + // IO. Anything else we don't handle → method-not-found. + let _ = write_line( + &stdin, + &json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32601, "message": "method not supported" } }), + ) + .await; + } + continue; + } + + // Notification from the agent. + if method == "session/update" { + handle_update(&app, &v, &turn).await; + } + } +} + +async fn handle_update(app: &AppHandle, v: &Value, turn: &Arc>) { + let update = match v.get("params").and_then(|p| p.get("update")) { + Some(u) => u, + None => return, + }; + let kind = update.get("sessionUpdate").and_then(|k| k.as_str()).unwrap_or(""); + let text = update + .get("content") + .and_then(|c| c.get("text")) + .and_then(|t| t.as_str()); + + match kind { + "agent_message_chunk" => { + let Some(text) = text else { return }; + let mut ts = turn.lock().await; + ts.full_content.push_str(text); + ts.pending_delta.push_str(text); + // Throttle: emit at most once per window; the trailing delta is flushed + // on `ollama:done`. + if ts.last_emit.elapsed() >= TOKEN_EMIT_WINDOW { + let _ = app.emit( + "ollama:token", + json!({ + "generation_id": ts.generation_id, + "token": ts.pending_delta, + "full_content": ts.full_content, + }), + ); + ts.pending_delta.clear(); + ts.last_emit = Instant::now(); + } + } + "agent_thought_chunk" => { + if let Some(text) = text { + let gid = turn.lock().await.generation_id.clone(); + let _ = app.emit( + "opencode:reasoning", + json!({ "generation_id": gid, "delta": text }), + ); + } + } + "tool_call" | "tool_call_update" => { + let gid = turn.lock().await.generation_id.clone(); + let _ = app.emit( + "opencode:tool_call", + json!({ "generation_id": gid, "update": update }), + ); + } + _ => {} + } +} + +/// Which opencode provider block a selected model maps to. +/// +/// The model string doubles as the routing signal so the SDK wire format doesn't have +/// to change: `ollama/` and `foundry/` are on-device, anything else is a +/// cloud ibl.ai compat id that already carries its own prefix (`openai/gpt-5.5`). +struct ModelSpec { + /// opencode provider key: "ollama" | "foundry" | "iblai". + provider: &'static str, + /// Bare model id as the runtime knows it (routing prefix stripped for local). + model: String, + /// On-device runtimes need an explicit baseURL and no ibl.ai auth. + local: bool, +} + +fn parse_model_spec(model: &str) -> ModelSpec { + if let Some(rest) = model.strip_prefix("ollama/") { + ModelSpec { provider: "ollama", model: rest.to_string(), local: true } + } else if let Some(rest) = model.strip_prefix("foundry/") { + ModelSpec { provider: "foundry", model: rest.to_string(), local: true } + } else { + ModelSpec { provider: "iblai", model: model.to_string(), local: false } + } +} + +/// Match model ids ignoring a `:tag` suffix on either side ("qwen3" ≡ "qwen3:latest"), +/// mirroring the frontend's `sameModel`. Catalog base names are unique, so this can't +/// select the wrong model. +fn same_model(a: &str, b: &str) -> bool { + a == b || a.split(':').next() == b.split(':').next() +} + +/// Point opencode at a model by patching its config: set the top-level `model`, keep +/// `enabled_providers` to just the active provider, and reset that provider's `models` +/// map to only the active model. `base_url` is `Some` for on-device runtimes (an +/// explicit, unauthenticated endpoint); cloud keeps the `{env:IBL_*}` placeholders that +/// are injected at spawn. +/// +// ponytail: patches the single shared config at ~/.config/iblai/agents/opencode — +// fine for one Code session at a time; give each session its own XDG_CONFIG_HOME if +// concurrent Code sessions with different models ever matter. +fn apply_opencode_model(spec: &ModelSpec, base_url: Option<&str>) -> Result<(), String> { + // The config ships embedded in the app and is materialized on first use — make + // sure it's on disk before we patch it (self-heal if install hasn't run yet). + crate::opencode_installer::ensure_opencode_config()?; + let path = config_home().join("opencode").join("opencode.json"); + let text = std::fs::read_to_string(&path) + .map_err(|e| format!("opencode config not found ({}): {e}", path.display()))?; + let mut cfg: Value = + serde_json::from_str(&text).map_err(|e| format!("bad opencode config: {e}"))?; + + let root = cfg + .as_object_mut() + .ok_or("opencode config is not a JSON object")?; + root.insert( + "model".to_string(), + json!(format!("{}/{}", spec.provider, spec.model)), + ); + // Only the active provider is enabled, so opencode never tries to resolve + // credentials for one we're not using this session. + root.insert("enabled_providers".to_string(), json!([spec.provider])); + + let providers = root + .entry("provider") + .or_insert_with(|| json!({})) + .as_object_mut() + .ok_or("opencode config `provider` is not an object")?; + let entry = providers + .entry(spec.provider.to_string()) + .or_insert_with(|| json!({})) + .as_object_mut() + .ok_or("opencode provider entry is not an object")?; + + // Reset to JUST the active model each spawn (no accumulation across sessions). + let mut models = serde_json::Map::new(); + models.insert(spec.model.clone(), json!({ "name": spec.model })); + entry.insert("models".to_string(), Value::Object(models)); + + if let Some(url) = base_url { + entry.insert("npm".to_string(), json!("@ai-sdk/openai-compatible")); + entry.insert( + "name".to_string(), + json!(if spec.provider == "ollama" { "Ollama (on-device)" } else { "Foundry Local (on-device)" }), + ); + // Local runtimes are unauthenticated; the placeholder key just satisfies + // @ai-sdk/openai-compatible, which expects the header to exist. + entry.insert( + "options".to_string(), + json!({ "baseURL": url, "apiKey": "local" }), + ); + } + + let out = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?; + std::fs::write(&path, out).map_err(|e| format!("failed writing opencode config: {e}")) +} + +/// Resolve the OpenAI-compatible base URL for an on-device runtime, starting Ollama +/// if it isn't up yet. +/// +/// NOTE: this deliberately targets PLAIN Ollama on :11434, NOT the `ollama-mcp-bridge` +/// on :8000 that `model_manager::chat_base_url` calls "the one and only chat port". +/// That rule governs the app's own chat path, which needs the bridge to inject MCP +/// tools. Code must not use it: the bridge speaks the *Ollama* API (`/api/chat`) while +/// opencode needs *OpenAI*-compatible `/v1/chat/completions`, and opencode ships its own +/// file/shell tools, so the bridge's injected tools would duplicate them. +async fn resolve_local_base_url(spec: &ModelSpec) -> Result { + match spec.provider { + "ollama" => { + if !crate::model_manager::wait_for_ollama_ready(1).await { + crate::model_manager::start_ollama_server()?; + if !crate::model_manager::wait_for_ollama_ready(20).await { + return Err("Ollama isn't running and couldn't be started.".to_string()); + } + } + Ok(format!( + "{}/v1", + crate::model_manager::OLLAMA_API_URL.trim_end_matches('/') + )) + } + "foundry" => { + let endpoint = crate::foundry_manager::get_foundry_service_endpoint() + .ok_or("Foundry Local isn't running — start it and try again.")?; + Ok(format!("{}/v1", endpoint.trim_end_matches('/'))) + } + other => Err(format!("unknown on-device runtime: {other}")), + } +} + +/// Foundry persists the UI id (e.g. "phi-3-mini-128k_npu") but its OpenAI-compatible +/// endpoint wants the runtime id (e.g. "phi-3-mini-128k-instruct-qnn-npu:2"). Mirror +/// the translation `foundry_chat_stream` does in main.rs, or Code sends an id Foundry +/// doesn't recognise. Falls back to the input when it can't be resolved. +async fn foundry_runtime_id(model: &str) -> String { + if let Ok(status) = crate::foundry_manager::check_foundry_status().await { + if let Some(m) = status + .models + .iter() + .find(|m| m.id == model || m.foundry_id == model) + { + return m.foundry_id.clone(); + } + } + model.to_string() +} + +/// Read a model's `capabilities` from Ollama's `/api/tags`. `None` = unknown (model +/// not installed, or Ollama unreachable). +async fn ollama_supports_tools(model: &str) -> Option { + let url = format!( + "{}/api/tags", + crate::model_manager::OLLAMA_API_URL.trim_end_matches('/') + ); + let body: Value = reqwest::get(&url).await.ok()?.json().await.ok()?; + let entry = body.get("models")?.as_array()?.iter().find(|m| { + m.get("name") + .and_then(|n| n.as_str()) + .map(|n| same_model(n, model)) + .unwrap_or(false) + })?; + let caps = entry.get("capabilities")?.as_array()?; + Some(caps.iter().any(|c| c.as_str() == Some("tools"))) +} + +/// Report whether the selected on-device model can actually drive Code. +/// +/// opencode is agentic: without tool calling it can't read/write files or run commands, +/// so it degrades to plain chat and looks broken. Ollama publishes per-model +/// `capabilities`, so we can refuse up front — the same rule +/// `model_manager::chat_base_url` already applies to local chat. Foundry Local exposes +/// no capability metadata, so it reports `null` (unknown) and the UI warns instead of +/// blocking. +/// `model` is the app's selected on-device model (`ibl_local_llm_model`, e.g. +/// "qwen3:latest"). It may carry an explicit `ollama/` or `foundry/` prefix; if not, +/// the runtime is auto-detected — Ollama when it's up and knows the model, else +/// Foundry Local. The returned `spec` is the prefixed string to persist as +/// `ibl_coding_mode_model`, which is what routes the spawn. +#[command] +pub async fn check_code_local_model(model: String) -> Value { + // Everything here is treated as on-device: `foundry/` picks Foundry, `ollama/` + // picks Ollama, and a bare id auto-detects (Ollama when it's up and knows the + // model, else Foundry). We never infer "cloud" from a slash — Ollama ids can + // contain one, e.g. `hf.co/user/model`. + if let Some(rest) = model.strip_prefix("foundry/") { + let runtime_id = foundry_runtime_id(rest).await; + return foundry_result( + &runtime_id, + crate::foundry_manager::get_foundry_service_endpoint(), + ); + } + let explicit_ollama = model.starts_with("ollama/"); + let bare = model.strip_prefix("ollama/").unwrap_or(&model).to_string(); + + if !crate::model_manager::wait_for_ollama_ready(1).await { + if !explicit_ollama { + if let Some(endpoint) = crate::foundry_manager::get_foundry_service_endpoint() { + return foundry_result(&foundry_runtime_id(&bare).await, Some(endpoint)); + } + } + return json!({ + "runtime": "ollama", "spec": format!("ollama/{bare}"), "model": bare, + "endpoint": Value::Null, "running": false, "tools_supported": Value::Null, + "reason": "Ollama isn't running." + }); + } + + let tools = ollama_supports_tools(&bare).await; + // Unknown to Ollama and the caller didn't insist on it → Foundry may serve it. + if tools.is_none() && !explicit_ollama { + if let Some(endpoint) = crate::foundry_manager::get_foundry_service_endpoint() { + return foundry_result(&bare, Some(endpoint)); + } + } + ollama_result(&bare, tools) +} + +fn ollama_result(model: &str, tools: Option) -> Value { + let reason = match tools { + Some(true) => String::new(), + Some(false) => format!( + "{model} doesn't support tool calling, so Code can't edit files or run commands with it." + ), + None => format!("Couldn't read tool support for {model} from Ollama."), + }; + json!({ + "runtime": "ollama", + "spec": format!("ollama/{model}"), + "model": model, + "endpoint": crate::model_manager::OLLAMA_API_URL, + "running": true, + "tools_supported": tools, + "reason": reason, + }) +} + +fn foundry_result(model: &str, endpoint: Option) -> Value { + match endpoint { + // Foundry Local publishes no per-model capability metadata, so tool support is + // unknown — the UI warns instead of blocking (blocking would disable Foundry). + Some(endpoint) => json!({ + "runtime": "foundry", "spec": format!("foundry/{model}"), "model": model, + "endpoint": endpoint, "running": true, "tools_supported": Value::Null, + "reason": "Foundry Local doesn't report tool-calling support — Code may not be able to edit files with this model." + }), + None => json!({ + "runtime": "foundry", "spec": format!("foundry/{model}"), "model": model, + "endpoint": Value::Null, "running": false, "tools_supported": Value::Null, + "reason": "Foundry Local isn't running." + }), + } +} + +/// Spawn `opencode acp`, run the ACP handshake, and register the session. +async fn spawn_session( + app: &AppHandle, + tenant: &str, + token: &str, + model: Option, + api_base: Option, + workspace: &PathBuf, +) -> Result, String> { + ensure_workspace(workspace)?; + + // Match opencode's model to the selection — NO default: an absent/empty model + // means Code doesn't run, so a broken selection fails loudly instead of silently + // falling back to some other model. + let chosen_model = match model.as_deref() { + Some(m) if !m.is_empty() => m.to_string(), + _ => return Err("no model selected for Code".to_string()), + }; + let spec = parse_model_spec(&chosen_model); + + // On-device runtimes get an explicit endpoint baked into the config; cloud keeps + // the `{env:IBL_*}` placeholders injected below. + let local_base = match spec.local { + true => Some(resolve_local_base_url(&spec).await?), + false => None, + }; + apply_opencode_model(&spec, local_base.as_deref())?; + + // Never hand the ibl.ai token to an on-device session that has no use for it. The + // vars are still set (empty) so the config's `{env:...}` placeholders resolve. + let (base_url, auth_header) = if spec.local { + (String::new(), String::new()) + } else { + let api_base = api_base.unwrap_or_else(|| DEFAULT_API_BASE.to_string()); + ( + format!("{}/api/ai-mentor/orgs/{}/v1", api_base.trim_end_matches('/'), tenant), + format!("Token {token}"), + ) + }; + + let mut cmd = create_command(&opencode_program()); + cmd.args(["acp", "--print-logs", "--log-level", "INFO"]) + .current_dir(workspace) + .env("XDG_CONFIG_HOME", config_home()) + .env("IBL_BASE_URL", &base_url) + .env("IBL_AUTH_HEADER", &auth_header) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + + let mut child = cmd.spawn().map_err(|e| { + format!("failed to launch `opencode acp` (is opencode installed?): {e}") + })?; + + let stdin = Arc::new(Mutex::new(child.stdin.take().ok_or("no stdin")?)); + let stdout = child.stdout.take().ok_or("no stdout")?; + let stderr = child.stderr.take().ok_or("no stderr")?; + + // Log opencode stderr (never contains the token; it lives only in env). + tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(l)) = lines.next_line().await { + eprintln!("[opencode] {l}"); + } + }); + + let pending: Arc>>> = + Arc::new(Mutex::new(HashMap::new())); + let turn = Arc::new(Mutex::new(TurnState { + generation_id: String::new(), + full_content: String::new(), + pending_delta: String::new(), + last_emit: Instant::now(), + })); + + // Start the reader BEFORE the handshake so responses are routed. + tokio::spawn(reader_loop( + app.clone(), + stdout, + stdin.clone(), + pending.clone(), + turn.clone(), + )); + + // A pre-session shell we can use to drive the handshake requests. + let mut session = Session { + child: Mutex::new(child), + stdin, + next_id: AtomicI64::new(1), + pending, + acp_session_id: String::new(), + spawn_token_exp: jwt_exp(token), + requested_model: model, + turn, + }; + + // 1) initialize — we advertise NO fs/terminal capabilities so opencode uses its + // own built-in file/shell tools directly on the workspace. + session + .request( + "initialize", + json!({ + "protocolVersion": 1, + "clientCapabilities": { "fs": { "readTextFile": false, "writeTextFile": false }, "terminal": false }, + "clientInfo": { "name": "ibl.ai", "title": "ibl.ai Coding Mode", "version": "1.0.0" } + }), + ) + .await?; + + // 2) session/new with the workspace cwd. + let new_res = session + .request( + "session/new", + json!({ "cwd": workspace.to_string_lossy(), "mcpServers": [] }), + ) + .await?; + let acp_session_id = new_res + .get("sessionId") + .and_then(|s| s.as_str()) + .ok_or("session/new returned no sessionId")? + .to_string(); + session.acp_session_id = acp_session_id; + + Ok(Arc::new(session)) +} + +/// Get an existing live session or spawn one, refreshing the process proactively +/// when the spawn token is near expiry. +async fn get_or_spawn( + app: &AppHandle, + session_id: &str, + tenant: &str, + token: &str, + model: Option, + api_base: Option, + workspace: &PathBuf, +) -> Result, String> { + { + let reg = registry().lock().await; + if let Some(s) = reg.get(session_id) { + let expiring = s + .spawn_token_exp + .map(|exp| exp - now_secs() < TOKEN_REFRESH_SLACK_SECS) + .unwrap_or(false); + let model_changed = s.requested_model.as_deref() != model.as_deref(); + if !expiring && !model_changed { + return Ok(s.clone()); + } + } + } + // Missing, token near expiry, or model switched → (re)spawn. + close_session(session_id).await; + let s = spawn_session(app, tenant, token, model, api_base, workspace).await?; + registry().lock().await.insert(session_id.to_string(), s.clone()); + Ok(s) +} + +async fn close_session(session_id: &str) { + if let Some(s) = registry().lock().await.remove(session_id) { + let mut child = s.child.lock().await; + let _ = child.start_kill(); + } +} + +/// Stream one Coding-Mode turn through opencode, emitting `ollama:*` + +/// `opencode:*` events keyed by `generation_id`. +#[command] +pub async fn opencode_chat_stream( + app: AppHandle, + session_id: String, + messages: Vec, + generation_id: String, + tenant: String, + token: String, + model: Option, + api_base: Option, + workspace: Option, +) -> Result<(), String> { + if crate::opencode_installer::is_sandboxed() { + return Err("Code isn't available in the sandboxed Mac App Store build.".to_string()); + } + let workspace = workspace.map(PathBuf::from).unwrap_or_else(resolve_workspace); + + let prompt_text = last_user_text(&messages) + .ok_or("no user message to send to opencode")?; + + // An on-device turn can sit silent for minutes the first time: opencode refreshes + // the models.dev registry into ~/.cache/opencode/models.json, and the runtime loads + // the model into memory. Both are one-off, but the chat would otherwise just hang + // with no explanation — emit the hint BEFORE spawning so it lands immediately. + if model.as_deref().map(|m| parse_model_spec(m).local).unwrap_or(false) { + let _ = app.emit( + "opencode:reasoning", + json!({ + "generation_id": generation_id, + "delta": "Warming up the on-device model — the first run can take a few minutes while it loads into memory.\n", + }), + ); + } + + let session = + get_or_spawn(&app, &session_id, &tenant, &token, model, api_base, &workspace).await?; + + // Reset per-turn state and snapshot the workspace before the agent runs. + session.turn.lock().await.reset(generation_id.clone()); + git_snapshot(&workspace, "opencode: pre-turn snapshot"); + + let res = session + .request( + "session/prompt", + json!({ + "sessionId": session.acp_session_id, + "prompt": [ { "type": "text", "text": prompt_text } ] + }), + ) + .await; + + // Snapshot again so auto-approved edits are captured/revertible. + git_snapshot(&workspace, "opencode: post-turn snapshot"); + + match res { + Ok(result) => { + let mut ts = session.turn.lock().await; + // Flush the final throttled delta as one last token so the committed + // message includes the tail — the last { + let _ = app.emit( + "ollama:error", + json!({ "generation_id": generation_id, "error": e }), + ); + Err(e) + } + } +} + +/// Cancel the active turn for a session (Stop button). +#[command] +pub async fn opencode_stop(session_id: String) -> Result<(), String> { + let session = { registry().lock().await.get(&session_id).cloned() }; + if let Some(s) = session { + s.notify("session/cancel", json!({ "sessionId": s.acp_session_id })) + .await?; + } + Ok(()) +} + +/// Kill and drop a session's opencode process (chat closed / idle cleanup). +#[command] +pub async fn opencode_close(session_id: String) -> Result<(), String> { + close_session(&session_id).await; + Ok(()) +} diff --git a/src-tauri/src/opencode_installer.rs b/src-tauri/src/opencode_installer.rs new file mode 100644 index 00000000..73cc01cf --- /dev/null +++ b/src-tauri/src/opencode_installer.rs @@ -0,0 +1,292 @@ +//! Auto-installer for the `opencode` binary used by Coding Mode. +//! +//! Downloads the pinned per-platform opencode release binary from GitHub into +//! `~/.local/share/iblai/bin/opencode` (no npm/node required — works for shipped +//! end users), writes the ibl.ai opencode config, and ensures the default +//! git-backed workspace. Mirrors the Ollama/Foundry installer shape. + +#![cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde_json::json; +use tauri::{command, AppHandle, Emitter}; + +use crate::opencode_acp::{iblai_data_dir, opencode_bin, opencode_program}; + +/// Pinned opencode version → GitHub release tag `v`. Bump ONLY after +/// testing ACP + config against it. Override at runtime with `IBL_OPENCODE_VERSION`. +const OPENCODE_VERSION: &str = "1.18.4"; + +/// The ibl.ai opencode config, installed at +/// `~/.config/iblai/agents/opencode/opencode.json`. baseURL + auth are injected as +/// env at spawn time; the top-level `model` and the provider `models` are filled in +/// per-session by `apply_opencode_model` from the user's selected LLM — there is no +/// baked-in default (see opencode_acp.rs). +const CONFIG_TEMPLATE: &str = r#"{ + "$schema": "https://opencode.ai/config.json", + "enabled_providers": ["iblai"], + "provider": { + "iblai": { + "npm": "@ai-sdk/openai-compatible", + "name": "ibl.ai", + "options": { + "baseURL": "{env:IBL_BASE_URL}", + "headers": { "Authorization": "{env:IBL_AUTH_HEADER}" } + }, + "models": {} + } + } +} +"#; + +fn create_command(program: &str) -> Command { + let cmd = Command::new(program); + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + let mut cmd = cmd; + cmd.creation_flags(CREATE_NO_WINDOW); + return cmd; + } + #[allow(unreachable_code)] + cmd +} + +fn log(app: &AppHandle, message: &str) { + println!("[opencode-install] {message}"); + let _ = app.emit( + "model:installation-log", + json!({ "message": message, "source": "opencode" }), + ); +} + +fn home_dir() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) +} + +fn config_file() -> PathBuf { + home_dir() + .unwrap_or_default() + .join(".config/iblai/agents/opencode/opencode.json") +} + +/// Whether opencode (managed binary or PATH) is runnable. +fn opencode_installed() -> bool { + create_command(&opencode_program()) + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn opencode_version() -> Option { + let out = create_command(&opencode_program()) + .arg("--version") + .output() + .ok()?; + if !out.status.success() { + return None; + } + Some(String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +/// (os, arch, ext) for the current platform's opencode release asset, e.g. +/// `opencode-darwin-arm64.zip` / `opencode-linux-x64.tar.gz`. +fn target_asset() -> Result<(&'static str, &'static str, &'static str), String> { + let os = match std::env::consts::OS { + "macos" => "darwin", + "linux" => "linux", + "windows" => "windows", + other => return Err(format!("unsupported OS: {other}")), + }; + let arch = match std::env::consts::ARCH { + "x86_64" => "x64", + "aarch64" => "arm64", + other => return Err(format!("unsupported arch: {other}")), + }; + let ext = if os == "linux" { "tar.gz" } else { "zip" }; + Ok((os, arch, ext)) +} + +/// Extract a `.tar.gz` (tar) or `.zip` (unzip on macOS, bsdtar elsewhere). +fn extract(archive: &Path, dir: &Path) -> Result<(), String> { + let a = archive.to_string_lossy().to_string(); + let d = dir.to_string_lossy().to_string(); + let mut cmd = if a.ends_with(".tar.gz") { + let mut c = create_command("tar"); + c.args(["-xzf", &a, "-C", &d]); + c + } else if cfg!(target_os = "macos") { + let mut c = create_command("unzip"); + c.args(["-o", &a, "-d", &d]); + c + } else { + // Windows/Linux: bsdtar handles .zip. + let mut c = create_command("tar"); + c.args(["-xf", &a, "-C", &d]); + c + }; + let status = cmd.status().map_err(|e| format!("extract spawn failed: {e}"))?; + if status.success() { + Ok(()) + } else { + Err(format!("extract failed (exit {:?})", status.code())) + } +} + +/// If the binary landed one directory deep, move it up to `target`. +fn hoist_binary(bin_dir: &Path, target: &Path) -> Result<(), String> { + if target.exists() { + return Ok(()); + } + let name = match target.file_name() { + Some(n) => n.to_owned(), + None => return Ok(()), + }; + if let Ok(rd) = std::fs::read_dir(bin_dir) { + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + let cand = p.join(&name); + if cand.exists() { + std::fs::rename(&cand, target).map_err(|x| x.to_string())?; + return Ok(()); + } + } + } + } + Ok(()) +} + +/// Download + install the pinned opencode binary into `~/.local/share/iblai/bin`. +async fn download_and_install(app: &AppHandle) -> Result<(), String> { + let version = std::env::var("IBL_OPENCODE_VERSION") + .unwrap_or_else(|_| OPENCODE_VERSION.to_string()); + let (os, arch, ext) = target_asset()?; + let asset = format!("opencode-{os}-{arch}.{ext}"); + let url = + format!("https://github.com/sst/opencode/releases/download/v{version}/{asset}"); + log(app, &format!("downloading {asset} (v{version})")); + + let bin_dir = iblai_data_dir().join("bin"); + std::fs::create_dir_all(&bin_dir).map_err(|e| format!("bin dir failed: {e}"))?; + let archive = bin_dir.join(format!("opencode-dl.{ext}")); + + let bytes = reqwest::get(&url) + .await + .map_err(|e| format!("download failed: {e}"))? + .error_for_status() + .map_err(|e| format!("download failed (bad status): {e}"))? + .bytes() + .await + .map_err(|e| format!("download read failed: {e}"))?; + std::fs::write(&archive, &bytes).map_err(|e| format!("archive write failed: {e}"))?; + + log(app, "extracting opencode"); + extract(&archive, &bin_dir)?; + let _ = std::fs::remove_file(&archive); + + let bin = opencode_bin(); + hoist_binary(&bin_dir, &bin)?; + if !bin.exists() { + return Err(format!( + "opencode binary not found in {} after extracting {asset}", + bin_dir.display() + )); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)); + } + #[cfg(target_os = "macos")] + { + // arm64 macOS SIGKILLs unsigned binaries, and a download can carry the + // `com.apple.quarantine` xattr — strip it + ad-hoc sign so opencode can spawn. + let _ = create_command("xattr") + .args(["-d", "com.apple.quarantine"]) + .arg(&bin) + .output(); + let _ = create_command("codesign") + .args(["--force", "--sign", "-"]) + .arg(&bin) + .output(); + log(app, "cleared quarantine + ad-hoc signed opencode"); + } + log(app, &format!("installed opencode at {}", bin.display())); + Ok(()) +} + +/// Write the ibl.ai opencode config if missing. Public so the ACP spawn path can +/// self-heal a missing config — the config ships embedded in the app (CONFIG_TEMPLATE), +/// not as a loose file on the user's disk, and is materialized on first use. +pub fn ensure_opencode_config() -> Result<(), String> { + let path = config_file(); + if path.exists() { + return Ok(()); + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("config dir failed: {e}"))?; + } + std::fs::write(&path, CONFIG_TEMPLATE).map_err(|e| format!("config write failed: {e}")) +} + +/// `ensure_opencode_config` + a log line on first write. +fn ensure_config(app: &AppHandle) -> Result<(), String> { + let existed = config_file().exists(); + ensure_opencode_config()?; + if !existed { + log(app, &format!("wrote opencode config at {}", config_file().display())); + } + Ok(()) +} + +/// Install opencode (if needed), write the config, and prepare the workspace. +#[command] +pub async fn install_opencode(app: AppHandle) -> Result { + if !opencode_installed() { + log(&app, "opencode not found — downloading"); + download_and_install(&app).await?; + if !opencode_installed() { + return Err("opencode still not runnable after install".to_string()); + } + } else { + log(&app, "opencode already installed"); + } + + ensure_config(&app)?; + + // Prepare the default (or persisted) workspace as a git repo. + let ws = crate::opencode_acp::resolve_workspace(); + std::fs::create_dir_all(&ws).map_err(|e| format!("workspace create failed: {e}"))?; + if !ws.join(".git").exists() { + let _ = create_command("git").arg("init").current_dir(&ws).output(); + } + log(&app, &format!("workspace ready at {}", ws.display())); + + Ok(opencode_version().unwrap_or_else(|| "installed".to_string())) +} + +/// macOS App Sandbox detection — the sandbox exports `APP_SANDBOX_CONTAINER_ID`. +/// Under the sandbox Code can't spawn the opencode binary (or freely touch the +/// filesystem), so the UI hides Code and the spawn path refuses when this is true. +pub fn is_sandboxed() -> bool { + cfg!(target_os = "macos") && std::env::var_os("APP_SANDBOX_CONTAINER_ID").is_some() +} + +/// Report opencode readiness for the UI. +#[command] +pub async fn check_opencode_status() -> serde_json::Value { + json!({ + "installed": opencode_installed(), + "version": opencode_version(), + "config_ready": config_file().exists(), + "workspace": crate::opencode_acp::resolve_workspace().to_string_lossy(), + "sandboxed": is_sandboxed(), + }) +}