Merge native Claude Code integration with explicit opt-in
# Conflicts: # src/agent/teacher-agent.ts # src/server/keys.ts
This commit is contained in:
@@ -38,6 +38,7 @@ import { useServerEvent, useServerResync } from "@/components/server-events";
|
||||
import {
|
||||
isProviderKeyRefusal,
|
||||
firstSourceTextParts,
|
||||
cliContextWindowOf,
|
||||
messageAnchorId,
|
||||
textPartMessageId,
|
||||
kbdEventForAction as kbdEventForChat,
|
||||
@@ -1691,6 +1692,7 @@ export function Chat({
|
||||
phase={phases.at(-1) ?? "probe"}
|
||||
turns={turns}
|
||||
contextTokens={contextTokens}
|
||||
cliContextWindow={cliContextWindowOf(nativeMessages)}
|
||||
pricing={pricing}
|
||||
onReserve={setDockReserve}
|
||||
dictationSlot={<DictationBar dictation={dictation} />}
|
||||
@@ -1917,6 +1919,7 @@ function DockOverlay({
|
||||
phase,
|
||||
turns,
|
||||
contextTokens,
|
||||
cliContextWindow,
|
||||
pricing,
|
||||
composer,
|
||||
onSubmitAnswer,
|
||||
@@ -1953,6 +1956,7 @@ function DockOverlay({
|
||||
/** The session's usage log — the stats bar beneath the composer folds it. */
|
||||
turns: TurnUsage[];
|
||||
contextTokens: number | null;
|
||||
cliContextWindow: ReturnType<typeof cliContextWindowOf>;
|
||||
pricing?: SessionPricing;
|
||||
composer: React.ReactNode;
|
||||
onSubmitAnswer: (output: QuestionAnswerData) => void;
|
||||
@@ -2408,6 +2412,7 @@ function DockOverlay({
|
||||
<StatsBar
|
||||
turns={turns}
|
||||
contextTokens={contextTokens}
|
||||
cliContextWindow={cliContextWindow}
|
||||
pricing={pricing}
|
||||
dictationSlot={dictationSlot}
|
||||
chatId={chatId}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function useModelCatalog(): { catalog: ModelCatalogResponse | undefined;
|
||||
catalogReaders.add(read);
|
||||
if (first) {
|
||||
stopCatalogSignals = onSettingsSaved((section) => {
|
||||
if (section === "keys") {
|
||||
if (section === "keys" || section === "claudeCode") {
|
||||
catalogGeneration++; catalogInflight = undefined; catalogCache = undefined;
|
||||
fetchCatalog();
|
||||
} else if (section === "models") fetchCatalog(true);
|
||||
|
||||
@@ -13,15 +13,12 @@ import { useModelCatalog } from "@/components/model-catalog";
|
||||
export { useModelCatalog } from "@/components/model-catalog";
|
||||
import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default";
|
||||
|
||||
/** provider:id → contextWindow over the cached catalog — the client-side
|
||||
* twin of the server's catalogContextLimits join (context-meter
|
||||
* amendment, owner 2026-09-23: pi's live catalog is the ONLY limit
|
||||
* source — no hardcoded fallback exists anywhere, so a model missing
|
||||
* from the index reads limit-null and the meter renders usage-only,
|
||||
* the honest-render precedent). Consumed by the stats bar: the same
|
||||
* cached /api/models fetch the picker rides feeds the denominator. */
|
||||
/** provider:id → known contextWindow over the cached catalog, mirroring
|
||||
* the server's catalogContextLimits join. CLI aliases have no global
|
||||
* denominator: the stats bar uses that session's actual CLI report instead.
|
||||
* Missing limits always render usage-only, without a hardcoded fallback. */
|
||||
export function catalogLimits(catalog: Pick<ModelCatalogResponse, "models">): Map<string, number> {
|
||||
return new Map(catalog.models.map((m) => [`${m.provider}:${m.id}`, m.contextWindow]));
|
||||
return new Map(catalog.models.flatMap((m) => m.contextWindow === null ? [] : [[`${m.provider}:${m.id}`, m.contextWindow] as const]));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,7 +20,7 @@ export const SETTINGS_SAVED_EVENT = "lh:settings-saved";
|
||||
// "keys" joined the roster (zero-keys wave, 2026-09-25): keys-section
|
||||
// dispatches it after save AND delete — deletions used to be invisible to
|
||||
// the zero-keys pill and the setup dialog's auto-advance.
|
||||
export type SettingsSection = "general" | "keys" | "agent" | "models" | "appearance" | "graph" | "compaction" | "dictation";
|
||||
export type SettingsSection = "general" | "keys" | "agent" | "models" | "appearance" | "graph" | "compaction" | "dictation" | "claudeCode";
|
||||
|
||||
/** Writers call this after the PUT resolves ok — never before (a failed
|
||||
* write notifies nothing; the record is unchanged). */
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus, type UserSettings } from "@/src/core";
|
||||
import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Toggle } from "./controls";
|
||||
|
||||
export function claudeCodeStatusLabel(status: ClaudeCodeStatus | null): string {
|
||||
switch (status?.state) {
|
||||
case "not-found": return "Not found";
|
||||
case "meets-minimum": return `Version ${status.version} · meets minimum`;
|
||||
case "update-required": return `Version ${status.version} · update required`;
|
||||
default: return "Version could not be checked";
|
||||
}
|
||||
}
|
||||
|
||||
export function ClaudeCodeInstallationStatus() {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<ClaudeCodeStatus | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const request = useRef<AbortController | null>(null);
|
||||
const settingsRead = useRef<AbortController | null>(null);
|
||||
const settingsWrite = useRef<AbortController | null>(null);
|
||||
|
||||
const read = useCallback(async () => {
|
||||
settingsRead.current?.abort();
|
||||
const controller = new AbortController();
|
||||
settingsRead.current = controller;
|
||||
setLoaded(false);
|
||||
try {
|
||||
const response = await fetch("/api/settings", { cache: "no-store", signal: controller.signal });
|
||||
if (!response.ok) throw new Error("settings unavailable");
|
||||
const body = await response.json() as { settings: UserSettings | null };
|
||||
if (!controller.signal.aborted) {
|
||||
setEnabled(body.settings?.claudeCode?.enabled === true);
|
||||
setLoaded(true);
|
||||
setError(null);
|
||||
}
|
||||
} catch {
|
||||
if (!controller.signal.aborted) setError("Could not load Claude Code settings.");
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
void read();
|
||||
return () => { settingsRead.current?.abort(); settingsWrite.current?.abort(); };
|
||||
}, [read]);
|
||||
useSettingsSaved((section) => { if (section === "claudeCode") void read(); });
|
||||
|
||||
const save = async (next: boolean) => {
|
||||
const controller = new AbortController();
|
||||
settingsWrite.current = controller;
|
||||
settingsRead.current?.abort();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch("/api/settings/claudeCode", { method: "PUT", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: next }), signal: controller.signal });
|
||||
if (!response.ok) throw new Error("settings write failed");
|
||||
const body = await response.json() as { settings: UserSettings };
|
||||
if (typeof body.settings?.claudeCode?.enabled !== "boolean") throw new Error("invalid settings response");
|
||||
if (!controller.signal.aborted) {
|
||||
setEnabled(body.settings.claudeCode.enabled);
|
||||
notifySettingsSaved("claudeCode");
|
||||
}
|
||||
} catch {
|
||||
if (!controller.signal.aborted) setError("Could not save Claude Code settings.");
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!enabled) return;
|
||||
request.current?.abort();
|
||||
const controller = new AbortController();
|
||||
request.current = controller;
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await fetch("/api/claude-code", { cache: "no-store", signal: controller.signal, headers: { "x-learnharness-claude-code": "1" } });
|
||||
if (!response.ok) throw new Error("status unavailable");
|
||||
const value = await response.json() as ClaudeCodeStatus;
|
||||
if (!["not-found", "meets-minimum", "update-required", "version-unchecked"].includes(value.state)
|
||||
|| value.minimumVersion !== CLAUDE_CODE_MINIMUM_VERSION
|
||||
|| (value.version !== null && (typeof value.version !== "string" || !/^\d+\.\d+\.\d+$/.test(value.version)))) throw new Error("invalid status");
|
||||
if (!controller.signal.aborted) setStatus(value);
|
||||
} catch {
|
||||
if (!controller.signal.aborted) setStatus(null);
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setBusy(false);
|
||||
}
|
||||
}, [enabled]);
|
||||
useEffect(() => {
|
||||
if (enabled) void refresh();
|
||||
else { request.current?.abort(); setStatus(null); setBusy(false); }
|
||||
return () => request.current?.abort();
|
||||
}, [enabled, refresh]);
|
||||
|
||||
return <div className="space-y-2" data-testid="claude-code-installation">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-prose text-(length:--lh-prose-size) text-foreground">Claude Code</span>
|
||||
<Toggle label="Claude Code" checked={enabled} disabled={!loaded || saving} onChange={(next) => void save(next)} />
|
||||
</div>
|
||||
{error && <p role="alert" className="text-xs text-red-400">{error}</p>}
|
||||
{enabled && <>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span role="status" className="text-xs text-muted-foreground">{busy ? "Checking…" : claudeCodeStatusLabel(status)}</span>
|
||||
<Button type="button" size="xs" variant="outline" className="cursor-pointer disabled:cursor-not-allowed" disabled={busy} onClick={() => void refresh()} data-testid="claude-code-check-again">Check again</Button>
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground"><strong className="font-medium">Install and authenticate Claude Code in your terminal, then select a Claude Code model here.</strong> LearnHarness starts it when needed—no background process required.</p>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">Checks installation/version only—not authentication, model access, or quota. Claude Code controls billing. Requires v{CLAUDE_CODE_MINIMUM_VERSION}+. <a href="https://code.claude.com/docs/en/setup" target="_blank" rel="noreferrer" className="underline underline-offset-2 hover:text-foreground">Setup ↗</a></p>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">Verify AI claims independently—they may be inaccurate, incomplete, misleading, or outdated.</p>
|
||||
</>}
|
||||
</div>;
|
||||
}
|
||||
@@ -4,11 +4,12 @@ import { createPortal } from "react-dom";
|
||||
import { useBackdropDismiss } from "@/components/backdrop-dismiss";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function ConnectionRow({ label, action, disabled, onAction, children, testid, actionTestid, labelClassName }: { label: string; action: string; disabled: boolean; onAction: () => void; children?: React.ReactNode; testid?: string; actionTestid?: string; labelClassName?: string }) {
|
||||
export function ConnectionRow({ label, action, actionVariant, disabled, onAction, children, testid, actionTestid, labelClassName }: { label: string; action: string; actionVariant?: "outline" | "ghost"; disabled: boolean; onAction: () => void; children?: React.ReactNode; testid?: string; actionTestid?: string; labelClassName?: string }) {
|
||||
const variant = actionVariant ?? (action === "Connect" ? "outline" : "ghost");
|
||||
return <div className="flex flex-wrap items-center gap-3" data-testid={testid}>
|
||||
<span className={labelClassName ?? "font-ui text-xs text-foreground"}>{label}</span>
|
||||
{children}
|
||||
<Button type="button" size="xs" variant={action === "Connect" ? "outline" : "ghost"} className={`cursor-pointer disabled:cursor-not-allowed ${action === "Connect" ? "" : "text-muted-foreground hover:text-foreground"}`} data-testid={actionTestid} disabled={disabled} onClick={onAction}>{action}</Button>
|
||||
<Button type="button" size="xs" variant={variant} className={`cursor-pointer disabled:cursor-not-allowed ${variant === "outline" ? "" : "text-muted-foreground hover:text-foreground"}`} data-testid={actionTestid} disabled={disabled} onClick={onAction}>{action}</Button>
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core";
|
||||
import { SettingsGroup } from "./controls.tsx";
|
||||
import { SubscriptionConnections } from "./subscription-connections.tsx";
|
||||
import { ClaudeCodeInstallationStatus } from "./claude-code-status.tsx";
|
||||
import { ProviderMenuList } from "./model-menu.tsx";
|
||||
import { MenuShell } from "@/components/menu-shell";
|
||||
import { notifySettingsSaved } from "@/components/settings-signal";
|
||||
@@ -471,6 +472,10 @@ export function KeysSection({
|
||||
<SubscriptionConnections />
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup label="External CLI" testid="group-external-cli">
|
||||
<ClaudeCodeInstallationStatus />
|
||||
</SettingsGroup>
|
||||
|
||||
{(["Dictation", "Search"] as const).map((label) => <SettingsGroup key={label} label={label} testid={label === "Dictation" ? "group-dictation" : "group-search"}>
|
||||
{toolRows.filter((status) => label === "Dictation" ? status.id === "DEEPGRAM_API_KEY" : status.id !== "DEEPGRAM_API_KEY").map((status) => (
|
||||
<KeyTableRow
|
||||
|
||||
@@ -21,11 +21,12 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ModelCatalogEntry } from "@/src/core";
|
||||
import { fuzzyScore, subscriptionLabel } from "@/src/core";
|
||||
import { CLAUDE_CODE_PROVIDER, fuzzyScore, subscriptionLabel } from "@/src/core";
|
||||
import { formatTokens } from "@/components/usage-format";
|
||||
import { SearchMenuList } from "./search-menu-list";
|
||||
|
||||
function priceLabel(cost: ModelCatalogEntry["cost"]): string {
|
||||
if (!cost) return "Unpriced";
|
||||
const fmt = (n: number) => `$${n < 1 ? n.toFixed(2).replace(/0$/, "") : n.toFixed(2)}`;
|
||||
return `${fmt(cost.input)}/${fmt(cost.output)}`;
|
||||
}
|
||||
@@ -72,13 +73,13 @@ export function ModelMenuList({
|
||||
renderItem={(m) => <>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-card-foreground">{m.name}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground" title={subscriptionLabel(`${m.provider}:${m.id}`) ? "Subscription connection" : "USD per 1M tokens in/out"}>
|
||||
{subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)}
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground" title={m.provider === CLAUDE_CODE_PROVIDER ? "Billing is determined by your Claude Code configuration; LearnHarness does not estimate it." : subscriptionLabel(`${m.provider}:${m.id}`) ? "Subscription connection" : m.cost ? "USD per 1M tokens in/out" : "Price unavailable"}>
|
||||
{m.provider === CLAUDE_CODE_PROVIDER ? "CLI billing" : subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center justify-between gap-2 text-muted-foreground">
|
||||
<span className="truncate">{subscriptionLabel(`${m.provider}:${m.id}`) ? `${subscriptionLabel(`${m.provider}:${m.id}`)} (subscription)` : m.provider} · {m.id}</span>
|
||||
<span className="shrink-0 tabular-nums">{formatTokens(m.contextWindow)}</span>
|
||||
<span className="shrink-0 tabular-nums">{m.contextWindow === null ? "—" : formatTokens(m.contextWindow)}</span>
|
||||
</span>
|
||||
</>}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { subscriptionLabels, subscriptionLabel, type SessionPricing, type TurnUsage } from "@/src/core";
|
||||
import { CLAUDE_CODE_PROVIDER, subscriptionLabels, subscriptionLabel, type cliContextWindowOf, type SessionPricing, type TurnUsage } from "@/src/core";
|
||||
import { formatTokens, formatUsd, planCostLabel, prettyModel } from "@/components/usage-format";
|
||||
import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker";
|
||||
|
||||
@@ -10,6 +10,15 @@ export function formatContextMeasurement(tokens: number | null, limit: number |
|
||||
return limit === null ? numerator : `${numerator} / ${formatTokens(limit)}`;
|
||||
}
|
||||
|
||||
/** CLI aliases have no global catalog limit: only this session's matching
|
||||
* response can supply a denominator. Other providers keep their catalog join. */
|
||||
export function contextLimitForModel(model: string | undefined, limits: ReadonlyMap<string, number> | undefined,
|
||||
cliWindow: ReturnType<typeof cliContextWindowOf> = null): number | null {
|
||||
if (!model) return null;
|
||||
if (model.startsWith(`${CLAUDE_CODE_PROVIDER}:`)) return cliWindow?.model === model ? cliWindow.tokens : null;
|
||||
return limits?.get(model) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The below-composer stats bar (wave 4 observability): the session's
|
||||
* money/context footprint as one quiet row beneath the composer —
|
||||
@@ -24,12 +33,14 @@ export function formatContextMeasurement(tokens: number | null, limit: number |
|
||||
* Spending arrives already priced by the server through Pi's catalog and
|
||||
* calculator. Missing cost stays "—", never fake zero. Context is a separate
|
||||
* completed-conversation measurement, never a money-log/summarizer fold or
|
||||
* transcript estimate. Its catalog denominator follows the selected model;
|
||||
* model changes preserve the numerator. Successful compaction clears it to ?.
|
||||
* transcript estimate. Its denominator follows the selected model, from the
|
||||
* catalog or the session's CLI report; model changes preserve the numerator.
|
||||
* Successful compaction clears the numerator and any old CLI report.
|
||||
*/
|
||||
export function StatsBar({
|
||||
turns,
|
||||
contextTokens = null,
|
||||
cliContextWindow = null,
|
||||
pricing,
|
||||
dictationSlot,
|
||||
chatId,
|
||||
@@ -38,6 +49,7 @@ export function StatsBar({
|
||||
}: {
|
||||
turns: TurnUsage[];
|
||||
contextTokens?: number | null;
|
||||
cliContextWindow?: ReturnType<typeof cliContextWindowOf>;
|
||||
pricing?: SessionPricing;
|
||||
/** The bound session (per-session models, owner 2026-09-28): present
|
||||
* on the in-session bar — the model control binds THE SESSION'S own
|
||||
@@ -86,18 +98,19 @@ export function StatsBar({
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
// Catalog-only denominator; the picker reports its effective selection.
|
||||
// Missing/pending catalog means usage-only, never a hardcoded maximum.
|
||||
// The picker reports its effective selection. CLI windows are session-
|
||||
// scoped; unknown metadata always means usage-only, never a guessed maximum.
|
||||
const { catalog } = useModelCatalog();
|
||||
const limits = useMemo(() => (catalog ? catalogLimits(catalog) : undefined), [catalog]);
|
||||
const limit = selectedModel ? limits?.get(selectedModel) ?? null : null;
|
||||
const limit = contextLimitForModel(selectedModel, limits, cliContextWindow);
|
||||
const contextLabel = formatContextMeasurement(contextTokens, limit);
|
||||
const { cost = 0, unpriced = turns.length, subscription = 0 } = pricing ?? {};
|
||||
// Honest cost: show dollars only when at least one turn priced;
|
||||
// otherwise "—" (never a fake $0.0000).
|
||||
const pricedCount = turns.length - unpriced - subscription;
|
||||
const providers = pricing?.subscriptionProviders ?? subscriptionLabels(turns.map((turn) => turn.model));
|
||||
const costLabel = planCostLabel(pricedCount > 0 ? cost : null, providers);
|
||||
const cliBilled = turns.some((turn) => turn.model.startsWith(`${CLAUDE_CODE_PROVIDER}:`));
|
||||
const costLabel = planCostLabel(pricedCount > 0 ? cost : null, cliBilled ? [...providers, "CLI"] : providers);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -151,6 +164,9 @@ export function StatsBar({
|
||||
{contextLabel}
|
||||
</span>
|
||||
</div>
|
||||
{selectedModel?.startsWith(`${CLAUDE_CODE_PROVIDER}:`) && (
|
||||
<p>Window reported by Claude Code; may be lower for auto-compaction.</p>
|
||||
)}
|
||||
<div className="flex justify-between gap-2">
|
||||
<span>{subscription ? "Usage" : "Total cost"}</span>
|
||||
<span className="tabular-nums text-card-foreground">{costLabel}</span>
|
||||
@@ -178,7 +194,7 @@ export function StatsBar({
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2 tabular-nums text-card-foreground">
|
||||
<span className="text-muted-foreground">{turn.steps.length} steps</span>
|
||||
<span>{subscriptionLabel(turn.model) ?? (c === null ? "—" : formatUsd(c))}</span>
|
||||
<span>{turn.model.startsWith(`${CLAUDE_CODE_PROVIDER}:`) ? "CLI" : subscriptionLabel(turn.model) ?? (c === null ? "—" : formatUsd(c))}</span>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user