From 16ec65a6812cb905f60b7e5b2bd42ff7f05540f5 Mon Sep 17 00:00:00 2001 From: karutoil Date: Thu, 6 Aug 2026 09:39:44 -0400 Subject: [PATCH 1/2] fix(web,install): wire model refresh UI and fix CLI-only install summary - web: add refresh_models / models_refreshed handling with optimistic modelsRefreshing spinner and ModelPicker Refresh control - install: report embedded core for CLI-only installs; avoid blank print_box continuations that abort set -e after success - docs: clarify that terminal-only prebuilts do not install catcode-core --- docs/installation.md | 7 +++- install.sh | 59 ++++++++++++++++++++--------- web/src/components/chat.tsx | 2 + web/src/components/header.tsx | 6 +++ web/src/components/ide/shell.tsx | 2 + web/src/components/model-picker.tsx | 39 ++++++++++++++++--- web/src/components/settings.tsx | 6 +++ web/src/lib/reducer.test.ts | 17 +++++++++ web/src/lib/reducer.ts | 21 ++++++++++ web/src/lib/types.ts | 11 ++++++ web/src/lib/use-agent.ts | 17 +++++++++ 11 files changed, 162 insertions(+), 25 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index 80cc59d..dace20d 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -28,7 +28,10 @@ The web service requires Node.js 22.13+. curl -fsSL https://raw.githubusercontent.com/catalystctl/catcode/refs/heads/master/install.sh | bash ``` -Installs the `catcode` TUI binary to `/usr/local/bin` (system-wide). +Installs the `catcode` TUI binary to `/usr/local/bin` (system-wide). The +prebuilt TUI embeds the Rust core and extracts it to +`~/.cache/catalyst-code/` on first run — a separate `catcode-core` on PATH is +**not** installed for terminal-only installs. ### Terminal + Web service (Linux & macOS) @@ -342,7 +345,7 @@ Removes (from code in `install.sh` `do_uninstall()`): - Systemd unit (stop → disable → remove file) or launchd plist (unload → delete) - `/usr/local/bin/catcode` -- `/usr/local/bin/catcode-core` +- `/usr/local/bin/catcode-core` (when present — only installed with `--with-web` / source builds) - Web bundle directory (`/opt/catalyst-code/web` or `~/Library/Application Support/catalyst-code/web`) - Installer state file (`/etc/catalyst-code/installer.state`) - The git repo clone (if built from source) is left untouched diff --git a/install.sh b/install.sh index f0d1490..0ffc20a 100644 --- a/install.sh +++ b/install.sh @@ -403,8 +403,8 @@ print_banner() { local mode="download (prebuilt)" $BUILD_FROM_SOURCE && mode="build-from-source" print_box "Catalyst Code — installer v${VERSION_DETECTED}" \ - "TUI (catcode) + core (catcode-core) -> PATH" \ - "optional 24/7 web service (Next.js, prebuilt)" \ + "TUI (catcode, core embedded) -> PATH" \ + "optional web service (+ catcode-core) via --with-web" \ "scope: system-wide | platform: ${PLATFORM} (${SVC_MGR})" printf " ${C_DIM}mode: %s | dry-run: %s${C_RST}\n\n" "$mode" "$DRY_RUN" } @@ -1557,6 +1557,17 @@ do_status() { summary_install() { local web_line="(not installed — run with --with-web)" local svc_line="" + local core_line + # Download CLI-only ships an embed_core TUI (extracts core to ~/.cache on + # first run). A separate catcode-core on PATH is only installed with --with-web + # (web service needs CATCODE_CORE) or --build-from-source. Do not probe + # $PREFIX for an existing binary — a leftover from a prior --with-web + # install would make a CLI-only summary lie about what this run installed. + if $WITH_WEB || $BUILD_FROM_SOURCE; then + core_line="core: $PREFIX/catcode-core" + else + core_line="core: embedded in TUI (extracted on first run)" + fi if $WITH_WEB; then local svc_id="$UNIT_NAME" [[ "$PLATFORM" == "Darwin" ]] && svc_id="$LAUNCHD_LABEL (launchd)" @@ -1570,16 +1581,22 @@ summary_install() { fi local expose_line="" $WITH_WEB && expose_line="expose: ${EXPOSE_MODE} origin: ${ORIGIN:-}" - print_box "✓ Installed ${APP_NAME} v${VERSION_DETECTED}" \ - "tui: $PREFIX/catcode" \ - "core: $PREFIX/catcode-core" \ - "web: $web_line" \ - "$expose_line" \ - "$svc_line" \ - - "update: catcode --update (or bash install.sh --update)" \ - "uninstall: bash install.sh --uninstall" \ + # Build the box line list without empty entries or a blank line after `\` + # (a bare newline mid-continuation makes bash try to execute the next + # string as a command — set -e then aborts the installer after success). + local box_lines=( + "tui: $PREFIX/catcode" + "$core_line" + "web: $web_line" + ) + [[ -n "$expose_line" ]] && box_lines+=("$expose_line") + [[ -n "$svc_line" ]] && box_lines+=("$svc_line") + box_lines+=( + "update: catcode --update (or bash install.sh --update)" + "uninstall: bash install.sh --uninstall" "log: ${LOG_FILE:-}" + ) + print_box "✓ Installed ${APP_NAME} v${VERSION_DETECTED}" "${box_lines[@]}" log_info "Run the TUI with: catcode" if $WITH_WEB && ! $SKIP_SERVICE; then if [[ "$PLATFORM" == "Darwin" ]]; then @@ -1601,12 +1618,20 @@ summary_update() { [[ "${WEB_INSTALLED:-no}" == yes ]] && web_line="http://${HOST}:${PORT} (restarted)" local expose_line="" [[ "${WEB_INSTALLED:-no}" == yes ]] && expose_line="expose: ${EXPOSE_MODE} origin: ${ORIGIN:-}" - print_box "✓ Updated ${APP_NAME} v${VERSION_DETECTED}" \ - "tui: $PREFIX/catcode" \ - "core: $PREFIX/catcode-core" \ - "web: $web_line" \ - "$expose_line" \ - "source: ${METHOD:-download} @ ${BASE_URL:-${REPO_DIR:-}}" + local core_line + if [[ "${WEB_INSTALLED:-no}" == yes ]] || $BUILD_FROM_SOURCE || $WITH_WEB; then + core_line="core: $PREFIX/catcode-core" + else + core_line="core: embedded in TUI (extracted on first run)" + fi + local box_lines=( + "tui: $PREFIX/catcode" + "$core_line" + "web: $web_line" + ) + [[ -n "$expose_line" ]] && box_lines+=("$expose_line") + box_lines+=("source: ${METHOD:-download} @ ${BASE_URL:-${REPO_DIR:-}}") + print_box "✓ Updated ${APP_NAME} v${VERSION_DETECTED}" "${box_lines[@]}" log_info "Run the TUI with: catcode" } diff --git a/web/src/components/chat.tsx b/web/src/components/chat.tsx index d64001b..b45c2fb 100644 --- a/web/src/components/chat.tsx +++ b/web/src/components/chat.tsx @@ -723,6 +723,7 @@ export function ChatInner({ agent, docked }: { agent: AgentApi; docked?: boolean provider={state.provider} models={state.models} selectedModel={state.selectedModel} + modelsRefreshing={state.modelsRefreshing} thinkingLevel={state.thinkingLevel} approvalMode={state.approvalMode} metrics={state.metrics} @@ -736,6 +737,7 @@ export function ChatInner({ agent, docked }: { agent: AgentApi; docked?: boolean theme={theme} onMenuClick={() => setSidebarOpen(true)} onSelectModel={agent.setModel} + onRefreshModels={() => void agent.refreshModels()} onSelectThinking={agent.setThinking} onSetApproval={agent.setApproval} onReconnect={agent.reconnect} diff --git a/web/src/components/header.tsx b/web/src/components/header.tsx index f5baee7..12d30ea 100644 --- a/web/src/components/header.tsx +++ b/web/src/components/header.tsx @@ -30,6 +30,8 @@ interface Props { provider: string; models: ModelInfo[]; selectedModel: string | null; + /** True while an on-demand model-list refresh is in flight. */ + modelsRefreshing?: boolean; thinkingLevel: string; approvalMode: string; metrics: Metrics | null; @@ -45,6 +47,8 @@ interface Props { theme?: string; onMenuClick?: () => void; onSelectModel: (id: string) => void; + /** Force-refresh multi-provider model cache. */ + onRefreshModels?: () => void; onSelectThinking: (level: string) => void; onSetApproval: (mode: "never" | "destructive" | "always") => void; onReconnect?: () => void; @@ -151,6 +155,8 @@ export function Header(props: Props) { onSelect={props.onSelectModel} variant="popover" onClose={() => setModelOpen(false)} + onRefresh={props.onRefreshModels} + refreshing={props.modelsRefreshing} /> )} diff --git a/web/src/components/ide/shell.tsx b/web/src/components/ide/shell.tsx index d9d4729..64ce1e8 100644 --- a/web/src/components/ide/shell.tsx +++ b/web/src/components/ide/shell.tsx @@ -556,11 +556,13 @@ export function IdeShell() { ready={agent.state.ready} models={agent.state.models} selectedModel={agent.state.selectedModel} + modelsRefreshing={agent.state.modelsRefreshing} thinkingLevel={agent.state.thinkingLevel} approvalMode={agent.state.approvalMode} autoCompact={agent.state.ready?.auto_compact ?? true} sandbox={agent.state.ready?.sandbox ?? "none"} onSelectModel={agent.setModel} + onRefreshModels={() => void agent.refreshModels()} onSelectThinking={agent.setThinking} onSetApproval={agent.setApproval} onSetBashTimeout={(secs) => void agent.setConfig("bash_timeout_secs", secs)} diff --git a/web/src/components/model-picker.tsx b/web/src/components/model-picker.tsx index 68cf2de..7944d92 100644 --- a/web/src/components/model-picker.tsx +++ b/web/src/components/model-picker.tsx @@ -13,7 +13,7 @@ import { useMemo, useState, type ReactNode } from "react"; import type { ModelInfo } from "@/lib/types"; import { formatTokens } from "@/lib/format"; -import { CheckIcon, ModelIcon, SearchIcon, BrainIcon, XIcon } from "./icons"; +import { CheckIcon, ModelIcon, SearchIcon, BrainIcon, XIcon, RefreshIcon } from "./icons"; interface Props { models: ModelInfo[]; @@ -23,6 +23,10 @@ interface Props { variant?: "popover" | "inline"; /** Called after a selection is made (e.g. to close the popover). */ onClose?: () => void; + /** Force-refresh the multi-provider model cache (`refresh_models`). */ + onRefresh?: () => void; + /** True while an on-demand refresh is in flight. */ + refreshing?: boolean; } const PROVIDER_LABELS: Record = { @@ -49,6 +53,8 @@ export function ModelPicker({ onSelect, variant = "inline", onClose, + onRefresh, + refreshing = false, }: Props) { const [query, setQuery] = useState(""); const [provider, setProvider] = useState(null); @@ -180,11 +186,32 @@ export function ModelPicker({ }) )} - {/* Result count footer */} - {models.length > 0 && ( -
- {filtered.length} of {models.length} models - {provider && ` · ${prettyProvider(provider)}`} + {/* Result count + optional refresh footer */} + {(models.length > 0 || onRefresh) && ( +
+ + {models.length > 0 + ? `${filtered.length} of ${models.length} models` + : "No models yet"} + {provider && ` · ${prettyProvider(provider)}`} + + {onRefresh && ( + + )}
)}
diff --git a/web/src/components/settings.tsx b/web/src/components/settings.tsx index be699e2..b9be94c 100644 --- a/web/src/components/settings.tsx +++ b/web/src/components/settings.tsx @@ -48,11 +48,15 @@ interface Props { ready: ReadyPayload | null; models: ModelInfo[]; selectedModel: string | null; + /** True while an on-demand model-list refresh is in flight. */ + modelsRefreshing?: boolean; thinkingLevel: string; approvalMode: string; autoCompact: boolean; sandbox: string; onSelectModel: (id: string) => void; + /** Force-refresh multi-provider model cache. */ + onRefreshModels?: () => void; onSelectThinking: (level: string) => void; onSetApproval: (mode: "never" | "destructive" | "always") => void; onSetBashTimeout: (secs: number) => void; @@ -784,6 +788,8 @@ export function SettingsModal(props: Props) { selectedModel={props.selectedModel} onSelect={props.onSelectModel} variant="inline" + onRefresh={props.onRefreshModels} + refreshing={props.modelsRefreshing} /> diff --git a/web/src/lib/reducer.test.ts b/web/src/lib/reducer.test.ts index e88a615..914d6be 100644 --- a/web/src/lib/reducer.test.ts +++ b/web/src/lib/reducer.test.ts @@ -765,6 +765,23 @@ describe("models rebinds selectedModel", () => { }); }); +describe("models_refreshed", () => { + test("clears modelsRefreshing and toasts the count", () => { + let s = reduce(initialState, { + type: "_set_models_refreshing", + refreshing: true, + }); + expect(s.modelsRefreshing).toBe(true); + s = reduce(s, { + type: "models_refreshed", + count: 3, + providers: { umans: ["a", "b", "c"] }, + }); + expect(s.modelsRefreshing).toBe(false); + expect(s.toasts.some((t) => t.message.includes("3 models"))).toBe(true); + }); +}); + describe("history tokens_in", () => { test("history with tokens_in seeds stats", () => { const s = reduce(initialState, { diff --git a/web/src/lib/reducer.ts b/web/src/lib/reducer.ts index 475542b..df1c8e2 100644 --- a/web/src/lib/reducer.ts +++ b/web/src/lib/reducer.ts @@ -58,6 +58,7 @@ export const initialState: AgentState = { providerModelsPreview: null, providerModelsPreviewError: null, selectedModel: null, + modelsRefreshing: false, thinkingLevel: "medium", messages: [], currentAssistantId: null, @@ -658,6 +659,8 @@ export function reduce(state: AgentState, ev: AgentEvent): AgentState { return { ...state, providerModelsPreview: null, providerModelsPreviewError: null }; case "_set_provider_models_preview_error": return { ...state, providerModelsPreviewError: ev.error }; + case "_set_models_refreshing": + return { ...state, modelsRefreshing: ev.refreshing }; case "_add_notifications": { // Client-only: append feed items emitted by useAgent's liveSessions diff. // Dedup per session+kind: refresh (bump ts) an existing UNREAD item for @@ -797,6 +800,23 @@ export function reduce(state: AgentState, ev: AgentEvent): AgentState { selectedModel: stillValid ? state.selectedModel : models[0]?.id ?? null, }; } + case "models_refreshed": { + // Terminal event for `refresh_models` (core already re-emitted `models`). + // Clear any optimistic spinner and confirm the count — parity with TUI. + const count = + typeof (ev as { count?: unknown }).count === "number" + ? (ev as { count: number }).count + : state.models.length; + return { + ...state, + modelsRefreshing: false, + toasts: pushToast( + state.toasts, + "info", + `Model list refreshed (${count} model${count === 1 ? "" : "s"})`, + ), + }; + } case "provider_presets": return { ...state, providerPresets: ev.presets ?? [] }; case "provider_models_preview": @@ -1439,6 +1459,7 @@ export function reduce(state: AgentState, ev: AgentEvent): AgentState { goalIterations: [], subagentRuns: {}, metrics: null, + modelsRefreshing: false, }; case "session_renamed": { const sessions = state.sessions.map((s) => diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index e6da64f..e382e0f 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -621,6 +621,12 @@ export interface GoalPlan { export type CoreEvent = | ReadyPayload | { type: "models"; models: ModelInfo[] } + /** Terminal event for `refresh_models` (after the `models` re-emit). */ + | { + type: "models_refreshed"; + count?: number; + providers?: Record; + } | { type: "provider_presets"; presets: ProviderPreset[] } | { type: "provider_models_preview"; models: ModelInfo[]; base_url: string; error?: string } | { type: "authed"; ok: boolean; provider: string } @@ -862,6 +868,7 @@ export type CoreCommand = | { type: "set_search_key"; provider: string; api_key: string } | { type: "set_provider"; name: string } | { type: "list_provider_presets" } + | { type: "refresh_models" } | { type: "login"; preset: string; api_key?: string } | { type: "add_custom_provider"; @@ -978,6 +985,8 @@ export type SyntheticEvent = | { type: "_goal_approve_optimistic" } | { type: "_clear_provider_models_preview" } | { type: "_set_provider_models_preview_error"; error: string | null } + /** Optimistic: an on-demand `refresh_models` is in flight. */ + | { type: "_set_models_refreshing"; refreshing: boolean } // Cross-session notification feed (client-only; derived in useAgent from // LiveSessionStatus transitions, never dispatched server-side). | { type: "_add_notifications"; items: NotificationItem[] } @@ -1127,6 +1136,8 @@ export interface AgentState { * hard failure). Cleared on a successful non-empty preview or on modal close. */ providerModelsPreviewError: string | null; selectedModel: string | null; + /** True while an on-demand `refresh_models` is in flight (optimistic UI). */ + modelsRefreshing: boolean; thinkingLevel: string; messages: UIMessage[]; currentAssistantId: string | null; diff --git a/web/src/lib/use-agent.ts b/web/src/lib/use-agent.ts index 317cf6d..808da98 100644 --- a/web/src/lib/use-agent.ts +++ b/web/src/lib/use-agent.ts @@ -154,6 +154,9 @@ export interface AgentApi { setConfig: (key: string, value: string | number | boolean) => Promise; // ── Memory extras ── refreshMemory: () => Promise; + // ── Models ── + /** Force-refresh the multi-provider model cache (`refresh_models`). */ + refreshModels: () => Promise; // ── Projects / workspace ── switchWorkspace: (path: string) => Promise; renameSession: (name: string, title: string) => Promise; @@ -1107,6 +1110,19 @@ export function useAgent(): AgentApi { const listPlugins = useCallback(() => fire({ type: "list_plugins" }), [fire]); const listAgents = useCallback(() => fire({ type: "list_agents" }), [fire]); const refreshMemory = useCallback(() => fire({ type: "refresh_memory" }), [fire]); + const refreshModels = useCallback(async () => { + // Optimistic spinner; cleared by the terminal `models_refreshed` event + // (or immediately if the post fails before core sees the command). + setState((s) => reduce(s, { type: "_set_models_refreshing", refreshing: true })); + try { + const ok = await send({ type: "refresh_models" }); + if (!ok) { + setState((s) => reduce(s, { type: "_set_models_refreshing", refreshing: false })); + } + } catch { + setState((s) => reduce(s, { type: "_set_models_refreshing", refreshing: false })); + } + }, [send]); // ── Skills ── const listSkills = useCallback(() => fire({ type: "list_skills" }), [fire]); @@ -1432,6 +1448,7 @@ export function useAgent(): AgentApi { listPlugins, listAgents, refreshMemory, + refreshModels, listSkills, applySkill, startGoal, From 3e0c3f15b1d60dab9130301c82598bf7c97084b0 Mon Sep 17 00:00:00 2001 From: karutoil Date: Thu, 6 Aug 2026 09:39:59 -0400 Subject: [PATCH 2/2] docs(changelog): note model refresh UI and provider surface hardening --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71a42ac..e5a108a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to **Catalyst Code** (formerly Umans Harness), day by day from first commit. +## 2026-08-06 + +- Wired web model-list refresh (`refresh_models` / `models_refreshed`) with spinner + ModelPicker control; fixed CLI-only install summary for embedded core. [16ec65a] +- Hardened multi-provider wire paths end-to-end (max_tokens floors, empty auth omit, Gemini tool results, SSE/usage coercion, discovery caps). [0a95f73] + ## 2026-08-05 - Fixed add-custom-provider: non-blocking model discovery, no Umans fallback on dead endpoints, cancel/error UX, and paste into TUI form fields. [5f852cb]