import { Action, ActionPanel, Alert, Icon, List, Toast, confirmAlert, openExtensionPreferences, showToast } from "@vicinae/api";
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { clearAllChats, clearOrphanedSnips, runAutoDelete } from "./lib/cleanup";
import { usePreferences } from "./lib/hooks/usePreferences";
import {
clearModelSelection,
describeAutoDelete,
describeMaxToolRounds,
describeResumeTimeout,
needsSetup,
type ResolvedPreferences,
} from "./lib/preferences";
import { listModels } from "./lib/ollama";
import { forgetGeoPlace } from "./lib/geo";
import { forgetVisionSupport, visionSupport, type VisionSupport } from "./lib/vision";
import type { ModelInfo } from "./lib/types";
import { errorMessage } from "./lib/utils";
/**
* Setup and diagnostics.
*
* Two things are worth understanding about how configuration works here.
*
* First, Vicinae asks for `required` preferences with no default *before* the
* command starts. The API key and the model are both declared that way in the
* manifest, so the two-step "key, then model" onboarding happens on the very
* first launch without any code in this file.
*
* Second, an extension can read preferences but cannot write them: there is no
* `setPreferenceValues`. So this command does not save anything. It verifies
* the connection, lists the models the key can actually reach, and hands the
* exact model name to the clipboard so it can be pasted into settings. That is
* more useful than a form that silently fights the launcher's own settings UI,
* and it means the model list can never drift out of sync with the real one.
*/
/** Shows enough of a key to recognise it without printing the whole thing. */
function maskKey(apiKey: string): string {
if (apiKey.length === 0) return "Not set";
if (apiKey.length <= 8) return "•".repeat(apiKey.length);
return `${apiKey.slice(0, 4)}${"•".repeat(6)}${apiKey.slice(-4)}`;
}
export default function SetupCommand() {
const { preferences, reload } = usePreferences();
// The screen does nothing until the model is known, because half of what it
// reports is about that model: whether it can see images, and whether it is the
// one from preferences or from the Models command. Rendering it with the
// preference value and correcting it a moment later would flash a wrong answer
// at the user.
if (!preferences) {
return (
);
}
return ;
}
interface SetupScreenProps {
preferences: ResolvedPreferences;
/** Re-reads preferences after this screen changes the model selection. */
reloadPreferences: () => void;
}
function SetupScreen({ preferences, reloadPreferences }: SetupScreenProps) {
const [models, setModels] = useState([]);
const [error, setError] = useState(undefined);
const [isLoading, setIsLoading] = useState(false);
const [hasTested, setHasTested] = useState(false);
const [vision, setVision] = useState("unknown");
const missingKey = needsSetup(preferences);
const testConnection = useCallback(async () => {
setIsLoading(true);
setError(undefined);
try {
const result = await listModels({ host: preferences.host, apiKey: preferences.apiKey });
setModels(result);
} catch (thrown) {
setModels([]);
setError(errorMessage(thrown));
} finally {
setHasTested(true);
setIsLoading(false);
}
// A model can gain or lose image support without its name changing (a
// re-pull, a retagged build), so re-testing drops the cached answer that the
// chat view gates the snip action on rather than leaving a stale "no".
await forgetVisionSupport();
// Same reasoning for the IP-derived place: a re-test is the moment to stop
// trusting an answer that was resolved hours ago, possibly in another city.
await forgetGeoPlace();
setVision(
await visionSupport({
host: preferences.host,
apiKey: preferences.apiKey,
model: preferences.model,
refresh: true,
}),
);
}, [preferences]);
// Verifying on open means the user sees something real immediately rather
// than having to guess whether their key works.
useEffect(() => {
if (!missingKey) void testConnection();
}, [missingKey, testConnection]);
// Opening any Lam command is a moment to collect expired conversations, and
// this is one of them. Nothing is open here, so nothing is exempt.
useEffect(() => {
void runAutoDelete({ maxAgeMs: preferences.autoDeleteMs });
}, [preferences.autoDeleteMs]);
const openPreferences = useCallback(() => {
void openExtensionPreferences();
}, []);
/**
* Hands the model back to the `Model` preference.
*
* The escape hatch for the one cost of keeping a selection: while one is set,
* editing the preference has no effect, and the only way to notice is this
* screen. Confirmed rather than immediate because it is a change the user
* cannot see the result of until the next question they ask.
*/
const resetModel = useCallback(async () => {
await clearModelSelection();
reloadPreferences();
await showToast({
style: Toast.Style.Success,
title: "Model reset to the preference",
message: preferences.preferenceModel,
});
}, [reloadPreferences, preferences.preferenceModel]);
/**
* Confirms, then runs a destructive tidy-up.
*
* Both actions on this screen delete things that cannot be recovered: there is
* no trash to undo them from, and a conversation's screenshots go with it. That
* is worth one extra keystroke, and the alert says what will go rather than
* asking "are you sure" about nothing in particular.
*/
const confirmThen = useCallback(
async (options: { title: string; message: string }, run: () => Promise) => {
const confirmed = await confirmAlert({
title: options.title,
message: options.message,
primaryAction: { title: "Delete", style: Alert.ActionStyle.Destructive },
});
if (confirmed) await run();
},
[],
);
/**
* The action panel for a maintenance row.
*
* The row's own action comes first, because it is why the user is looking at
* the row, and the two read-only actions sit in their own section below so they
* can never be the default that Return fires.
*/
const maintenanceActions = (primary: ReactNode) => (
{primary}
void testConnection()}
/>
);
const settingsActions = (
void testConnection()}
/>
);
const statusText = (() => {
if (missingKey) return "No API key yet";
if (isLoading) return "Checking…";
if (error) return "Connection failed";
if (hasTested) return `Connected · ${models.length} models`;
return "Not checked";
})();
const statusIcon = error || missingKey ? Icon.QuestionMarkCircle : Icon.SpeechBubble;
return (
void resetModel()}
/>
void testConnection()}
/>
) : (
settingsActions
)
}
/>
{/*
* Maintenance sits below the settings it acts on. Both rows are destructive
* rather than settings, so each builds its own action panel instead of
* sharing the one above: these are the only two things on this screen that
* delete anything, and neither should be one keystroke away from a row the
* user was only reading.
*/}
void confirmThen(
{
title: "Delete unattached photos?",
message:
"Screenshots that belong to no conversation will be deleted. Photos still attached to a chat are kept.",
},
clearOrphanedSnips,
)
}
/>,
)}
/>
void confirmThen(
{
title: "Delete every conversation?",
message:
"Every conversation and every screenshot it captured will be deleted. This cannot be undone.",
},
clearAllChats,
)
}
/>,
)}
/>
{error ? (
) : null}
{missingKey ? (
}
/>
) : null}
{models.length > 0 ? (
{models.map((model) => (
}
/>
))}
) : null}
);
}